Skip to main content

stmo_cli/commands/
deploy.rs

1#![allow(clippy::missing_errors_doc)]
2
3use crate::api::RedashClient;
4use crate::models::Query;
5use anyhow::{Context, Result, bail};
6use std::collections::HashSet;
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10
11fn slugify(s: &str) -> String {
12    s.to_lowercase()
13        .chars()
14        .map(|c| if c.is_alphanumeric() { c } else { '-' })
15        .collect::<String>()
16        .split('-')
17        .filter(|s| !s.is_empty())
18        .collect::<Vec<_>>()
19        .join("-")
20}
21
22fn validate_enum_options(metadata: &crate::models::QueryMetadata, yaml_path: &str) -> Result<()> {
23    for param in &metadata.options.parameters {
24        if let Some(enum_opts) = &param.enum_options
25            && enum_opts.contains("\\n")
26        {
27            bail!(
28                "In {yaml_path}: parameter '{}' has enumOptions with escaped newlines. \
29                Use YAML multiline format instead:\n\n\
30                enumOptions: |-\n  option1\n  option2",
31                param.name
32            );
33        }
34    }
35    Ok(())
36}
37
38fn get_changed_query_ids() -> Option<HashSet<u64>> {
39    let output = Command::new("git")
40        .args(["status", "--porcelain"])
41        .output()
42        .ok()?;
43
44    if !output.status.success() {
45        return None;
46    }
47
48    let stdout = String::from_utf8(output.stdout).ok()?;
49
50    let mut changed_ids = HashSet::new();
51
52    for line in stdout.lines() {
53        if line.len() < 3 {
54            continue;
55        }
56
57        let file_path = &line[3..];
58        let path = Path::new(file_path);
59
60        if file_path.starts_with("queries/")
61            && path.extension().is_some_and(|ext| {
62                ext.eq_ignore_ascii_case("sql") || ext.eq_ignore_ascii_case("yaml")
63            })
64            && let Some(filename) = file_path.strip_prefix("queries/")
65            && let Some(id_str) = filename.split('-').next()
66            && let Ok(id) = id_str.parse::<u64>()
67        {
68            changed_ids.insert(id);
69        }
70    }
71
72    Some(changed_ids)
73}
74
75fn get_all_query_metadata() -> Result<Vec<(u64, String)>> {
76    let queries_dir = Path::new("queries");
77
78    if !queries_dir.exists() {
79        bail!("queries directory not found. Run 'stmo-cli fetch' first.");
80    }
81
82    let mut queries = Vec::new();
83
84    for entry in fs::read_dir(queries_dir).context("Failed to read queries 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: crate::models::QueryMetadata = serde_yaml::from_str(&metadata_content)
93                .context(format!("Failed to parse {}", path.display()))?;
94
95            queries.push((metadata.id, metadata.name));
96        }
97    }
98
99    queries.sort_by_key(|(id, _)| *id);
100
101    Ok(queries)
102}
103
104async fn deploy_visualizations(
105    client: &RedashClient,
106    query_id: u64,
107    visualizations: &[crate::models::VisualizationMetadata],
108    server_visualizations: &[crate::models::Visualization],
109) -> Result<()> {
110    let mut matched_server_ids: HashSet<u64> = HashSet::new();
111    for viz in visualizations {
112        if let Some(id) = viz.id {
113            matched_server_ids.insert(id);
114            let viz_to_update = crate::models::Visualization {
115                id,
116                name: viz.name.clone(),
117                viz_type: viz.viz_type.clone(),
118                options: viz.options.clone(),
119                description: viz.description.clone(),
120            };
121            client.update_visualization(&viz_to_update).await?;
122            println!("    ✓ Updated visualization: {} (ID: {id})", viz.name);
123        } else {
124            let server_match = server_visualizations
125                .iter()
126                .find(|sv| sv.viz_type == viz.viz_type && !matched_server_ids.contains(&sv.id));
127            if let Some(server_viz) = server_match {
128                matched_server_ids.insert(server_viz.id);
129                let viz_to_update = crate::models::Visualization {
130                    id: server_viz.id,
131                    name: viz.name.clone(),
132                    viz_type: viz.viz_type.clone(),
133                    options: viz.options.clone(),
134                    description: viz.description.clone(),
135                };
136                client.update_visualization(&viz_to_update).await?;
137                println!(
138                    "    ✓ Updated visualization: {} (ID: {})",
139                    viz_to_update.name, server_viz.id
140                );
141            } else {
142                let viz_to_create = crate::models::CreateVisualization {
143                    query_id,
144                    name: viz.name.clone(),
145                    viz_type: viz.viz_type.clone(),
146                    options: viz.options.clone(),
147                    description: viz.description.clone(),
148                };
149                let created = client
150                    .create_visualization(query_id, &viz_to_create)
151                    .await?;
152                println!(
153                    "    ✓ Created visualization: {} (ID: {})",
154                    created.name, created.id
155                );
156            }
157        }
158    }
159    Ok(())
160}
161
162#[allow(clippy::too_many_lines)]
163pub async fn deploy_one(client: &RedashClient, id: u64, name: &str) -> Result<Query> {
164    let slug = slugify(name);
165    let sql_path = format!("queries/{id}-{slug}.sql");
166    let yaml_path = format!("queries/{id}-{slug}.yaml");
167
168    if !Path::new(&sql_path).exists() {
169        bail!("Query SQL file not found: {sql_path}");
170    }
171    if !Path::new(&yaml_path).exists() {
172        bail!("Query metadata file not found: {yaml_path}");
173    }
174
175    let sql = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
176
177    let metadata_content =
178        fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
179
180    let metadata: crate::models::QueryMetadata =
181        serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
182
183    validate_enum_options(&metadata, &yaml_path)?;
184
185    let result_query = if id == 0 {
186        let create_query = crate::models::CreateQuery {
187            name: metadata.name.clone(),
188            description: metadata.description.clone(),
189            sql,
190            data_source_id: metadata.data_source_id,
191            schedule: metadata.schedule.clone(),
192            options: Some(metadata.options.clone()),
193            tags: metadata.tags.clone(),
194            is_archived: false,
195            is_draft: false,
196        };
197        let created = client.create_query(&create_query).await?;
198        let fetched = client.get_query(created.id).await?;
199        let new_slug = slugify(&fetched.name);
200        let new_base = format!("queries/{}-{new_slug}", fetched.id);
201        fs::write(format!("{new_base}.sql"), &fetched.sql)
202            .context(format!("Failed to write {new_base}.sql"))?;
203        let mut new_visualizations: Vec<crate::models::VisualizationMetadata> = fetched
204            .visualizations
205            .iter()
206            .map(crate::models::VisualizationMetadata::from)
207            .collect();
208        new_visualizations.sort_by_key(|v| v.id);
209        let new_metadata = crate::models::QueryMetadata {
210            id: fetched.id,
211            name: fetched.name.clone(),
212            description: fetched.description.clone(),
213            data_source_id: fetched.data_source_id,
214            user_id: fetched.user.as_ref().map(|u| u.id),
215            schedule: fetched.schedule.clone(),
216            options: fetched.options.clone(),
217            visualizations: new_visualizations,
218            tags: fetched.tags.clone(),
219        };
220        let yaml_content =
221            serde_yaml::to_string(&new_metadata).context("Failed to serialize query metadata")?;
222        fs::write(format!("{new_base}.yaml"), yaml_content)
223            .context(format!("Failed to write {new_base}.yaml"))?;
224        fs::remove_file(&sql_path).context(format!("Failed to delete {sql_path}"))?;
225        fs::remove_file(&yaml_path).context(format!("Failed to delete {yaml_path}"))?;
226        println!("  ✓ Created new query: {} - {name}", fetched.id);
227        println!("    Renamed: 0-{slug}.* → {}-{new_slug}.*", fetched.id);
228        fetched
229    } else {
230        let query = Query {
231            id: metadata.id,
232            name: metadata.name.clone(),
233            description: metadata.description.clone(),
234            sql,
235            data_source_id: metadata.data_source_id,
236            user: None,
237            schedule: metadata.schedule.clone(),
238            options: metadata.options.clone(),
239            visualizations: vec![],
240            tags: metadata.tags.clone(),
241            is_archived: false,
242            is_draft: false,
243            updated_at: String::new(),
244            created_at: String::new(),
245        };
246        let result = client.create_or_update_query(&query).await?;
247        let fetched = client.get_query(id).await?;
248        let mut updated_visualizations: Vec<crate::models::VisualizationMetadata> = fetched
249            .visualizations
250            .iter()
251            .map(crate::models::VisualizationMetadata::from)
252            .collect();
253        updated_visualizations.sort_by_key(|v| v.id);
254        let updated_metadata = crate::models::QueryMetadata {
255            id: fetched.id,
256            name: fetched.name.clone(),
257            description: fetched.description.clone(),
258            data_source_id: fetched.data_source_id,
259            user_id: fetched.user.as_ref().map(|u| u.id),
260            schedule: fetched.schedule.clone(),
261            options: fetched.options.clone(),
262            visualizations: updated_visualizations,
263            tags: fetched.tags.clone(),
264        };
265        let yaml_content = serde_yaml::to_string(&updated_metadata)
266            .context("Failed to serialize query metadata")?;
267        fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
268        println!("  ✓ {id} - {name}");
269        result
270    };
271
272    deploy_visualizations(
273        client,
274        result_query.id,
275        &metadata.visualizations,
276        &result_query.visualizations,
277    )
278    .await?;
279
280    Ok(result_query)
281}
282
283pub async fn deploy(client: &RedashClient, query_ids: Vec<u64>, all: bool) -> Result<()> {
284    let all_queries = get_all_query_metadata()?;
285
286    let queries_to_deploy = if !query_ids.is_empty() {
287        let ids_set: HashSet<_> = query_ids.iter().copied().collect();
288        let filtered: Vec<_> = all_queries
289            .into_iter()
290            .filter(|(id, _)| ids_set.contains(id))
291            .collect();
292
293        if filtered.is_empty() {
294            bail!("None of the specified query IDs were found in queries/ directory");
295        }
296
297        println!("Deploying {} specific queries...", filtered.len());
298        for (id, name) in &filtered {
299            println!("  → {id} - {name}");
300        }
301        println!();
302
303        filtered
304    } else if all {
305        println!("Deploying all {} queries...\n", all_queries.len());
306        all_queries
307    } else {
308        let Some(changed_ids) = get_changed_query_ids() else {
309            println!("No git repository detected.");
310            println!("Tip: Use --all to deploy all queries, or specify query IDs.");
311            return Ok(());
312        };
313
314        if changed_ids.is_empty() {
315            println!("No changed queries detected.");
316            println!("Tip: Use --all to deploy all queries regardless of git status.");
317            return Ok(());
318        }
319
320        let filtered: Vec<_> = all_queries
321            .into_iter()
322            .filter(|(id, _)| changed_ids.contains(id))
323            .collect();
324
325        println!("Deploying {} changed queries...", filtered.len());
326        for (id, name) in &filtered {
327            println!("  → {id} - {name}");
328        }
329        println!();
330
331        filtered
332    };
333
334    for (id, name) in &queries_to_deploy {
335        deploy_one(client, *id, name).await?;
336    }
337
338    println!("\n✓ All resources deployed successfully");
339
340    Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_validate_enum_options_rejects_escaped_newlines() {
349        let metadata = crate::models::QueryMetadata {
350            id: 1,
351            name: "Test Query".to_string(),
352            description: None,
353            data_source_id: 1,
354            user_id: None,
355            schedule: None,
356            options: crate::models::QueryOptions {
357                parameters: vec![crate::models::Parameter {
358                    name: "test_param".to_string(),
359                    title: "Test Param".to_string(),
360                    param_type: "enum".to_string(),
361                    enum_options: Some("option1\\noption2\\noption3".to_string()),
362                    query_id: Some(1),
363                    value: None,
364                    multi_values_options: None,
365                }],
366            },
367            visualizations: vec![],
368            tags: None,
369        };
370
371        let result = validate_enum_options(&metadata, "test.yaml");
372        assert!(result.is_err());
373        let err_msg = result.unwrap_err().to_string();
374        assert!(err_msg.contains("escaped newlines"));
375        assert!(err_msg.contains("test_param"));
376        assert!(err_msg.contains("YAML multiline format"));
377    }
378
379    #[test]
380    fn test_validate_enum_options_accepts_multiline() {
381        let metadata = crate::models::QueryMetadata {
382            id: 1,
383            name: "Test Query".to_string(),
384            description: None,
385            data_source_id: 1,
386            user_id: None,
387            schedule: None,
388            options: crate::models::QueryOptions {
389                parameters: vec![crate::models::Parameter {
390                    name: "test_param".to_string(),
391                    title: "Test Param".to_string(),
392                    param_type: "enum".to_string(),
393                    enum_options: Some("option1\noption2\noption3".to_string()),
394                    query_id: Some(1),
395                    value: None,
396                    multi_values_options: None,
397                }],
398            },
399            visualizations: vec![],
400            tags: None,
401        };
402
403        let result = validate_enum_options(&metadata, "test.yaml");
404        assert!(result.is_ok());
405    }
406
407    #[test]
408    fn test_validate_enum_options_accepts_no_enum() {
409        let metadata = crate::models::QueryMetadata {
410            id: 1,
411            name: "Test Query".to_string(),
412            description: None,
413            data_source_id: 1,
414            user_id: None,
415            schedule: None,
416            options: crate::models::QueryOptions {
417                parameters: vec![crate::models::Parameter {
418                    name: "test_param".to_string(),
419                    title: "Test Param".to_string(),
420                    param_type: "text".to_string(),
421                    enum_options: None,
422                    query_id: Some(1),
423                    value: None,
424                    multi_values_options: None,
425                }],
426            },
427            visualizations: vec![],
428            tags: None,
429        };
430
431        let result = validate_enum_options(&metadata, "test.yaml");
432        assert!(result.is_ok());
433    }
434}