Skip to main content

stmo_cli/commands/
deploy.rs

1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{bail, Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use std::collections::HashSet;
8use crate::api::RedashClient;
9use crate::models::Query;
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() -> Result<HashSet<u64>> {
39    let output = Command::new("git")
40        .args(["status", "--porcelain"])
41        .output()
42        .context("Failed to run git status. Make sure you're in a git repository.")?;
43
44    if !output.status.success() {
45        bail!("git status command failed");
46    }
47
48    let stdout = String::from_utf8(output.stdout)
49        .context("Failed to parse git status output")?;
50
51    let mut changed_ids = HashSet::new();
52
53    for line in stdout.lines() {
54        if line.len() < 3 {
55            continue;
56        }
57
58        let file_path = &line[3..];
59        let path = Path::new(file_path);
60
61        if file_path.starts_with("queries/")
62            && path.extension().is_some_and(|ext| {
63                ext.eq_ignore_ascii_case("sql") || ext.eq_ignore_ascii_case("yaml")
64            })
65            && let Some(filename) = file_path.strip_prefix("queries/")
66            && let Some(id_str) = filename.split('-').next()
67            && let Ok(id) = id_str.parse::<u64>()
68        {
69            changed_ids.insert(id);
70        }
71    }
72
73    Ok(changed_ids)
74}
75
76fn get_all_query_metadata() -> Result<Vec<(u64, String)>> {
77    let queries_dir = Path::new("queries");
78
79    if !queries_dir.exists() {
80        bail!("queries directory not found. Run 'stmo-cli fetch' first.");
81    }
82
83    let mut queries = Vec::new();
84
85    for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
86        let entry = entry.context("Failed to read directory entry")?;
87        let path = entry.path();
88
89        if path.extension().is_some_and(|ext| ext == "yaml") {
90            let metadata_content = fs::read_to_string(&path)
91                .context(format!("Failed to read {}", path.display()))?;
92
93            let metadata: crate::models::QueryMetadata = serde_yaml::from_str(&metadata_content)
94                .context(format!("Failed to parse {}", path.display()))?;
95
96            queries.push((metadata.id, metadata.name));
97        }
98    }
99
100    queries.sort_by_key(|(id, _)| *id);
101
102    Ok(queries)
103}
104
105async fn deploy_visualizations(
106    client: &RedashClient,
107    query_id: u64,
108    visualizations: &[crate::models::Visualization],
109) -> Result<()> {
110    for viz in visualizations {
111        if viz.id == 0 {
112            let viz_to_create = crate::models::CreateVisualization {
113                query_id,
114                name: viz.name.clone(),
115                viz_type: viz.viz_type.clone(),
116                options: viz.options.clone(),
117                description: viz.description.clone(),
118            };
119            let created = client.create_visualization(query_id, &viz_to_create).await?;
120            println!("    ✓ Created visualization: {} (ID: {})", created.name, created.id);
121        } else {
122            client.update_visualization(viz).await?;
123        }
124    }
125    Ok(())
126}
127
128pub async fn deploy(client: &RedashClient, query_ids: Vec<u64>, all: bool) -> Result<()> {
129    let all_queries = get_all_query_metadata()?;
130
131    let queries_to_deploy = if !query_ids.is_empty() {
132        let ids_set: HashSet<_> = query_ids.iter().copied().collect();
133        let filtered: Vec<_> = all_queries
134            .into_iter()
135            .filter(|(id, _)| ids_set.contains(id))
136            .collect();
137
138        if filtered.is_empty() {
139            bail!("None of the specified query IDs were found in queries/ directory");
140        }
141
142        println!("Deploying {} specific queries...", filtered.len());
143        for (id, name) in &filtered {
144            println!("  → {id} - {name}");
145        }
146        println!();
147
148        filtered
149    } else if all {
150        println!("Deploying all {} queries...\n", all_queries.len());
151        all_queries
152    } else {
153        let changed_ids = get_changed_query_ids()?;
154
155        if changed_ids.is_empty() {
156            println!("No changed queries detected.");
157            println!("Tip: Use --all to deploy all queries regardless of git status.");
158            return Ok(());
159        }
160
161        let filtered: Vec<_> = all_queries
162            .into_iter()
163            .filter(|(id, _)| changed_ids.contains(id))
164            .collect();
165
166        println!("Deploying {} changed queries...", filtered.len());
167        for (id, name) in &filtered {
168            println!("  → {id} - {name}");
169        }
170        println!();
171
172        filtered
173    };
174
175    for (id, name) in &queries_to_deploy {
176        let slug = slugify(name);
177        let sql_path = format!("queries/{id}-{slug}.sql");
178        let yaml_path = format!("queries/{id}-{slug}.yaml");
179
180        if !Path::new(&sql_path).exists() {
181            bail!("Query SQL file not found: {sql_path}");
182        }
183        if !Path::new(&yaml_path).exists() {
184            bail!("Query metadata file not found: {yaml_path}");
185        }
186
187        let sql = fs::read_to_string(&sql_path)
188            .context(format!("Failed to read {sql_path}"))?;
189
190        let metadata_content = fs::read_to_string(&yaml_path)
191            .context(format!("Failed to read {yaml_path}"))?;
192
193        let metadata: crate::models::QueryMetadata = serde_yaml::from_str(&metadata_content)
194            .context(format!("Failed to parse {yaml_path}"))?;
195
196        validate_enum_options(&metadata, &yaml_path)?;
197
198        let result_query = if *id == 0 {
199            let create_query = crate::models::CreateQuery {
200                name: metadata.name.clone(),
201                description: metadata.description.clone(),
202                sql,
203                data_source_id: metadata.data_source_id,
204                schedule: metadata.schedule.clone(),
205                options: Some(metadata.options.clone()),
206                tags: metadata.tags.clone(),
207                is_archived: false,
208                is_draft: false,
209            };
210            let created = client.create_query(&create_query).await?;
211            println!("  ✓ Created new query: {} - {name}", created.id);
212            println!("    Update the YAML file with the new ID: {}", created.id);
213            created
214        } else {
215            let query = Query {
216                id: metadata.id,
217                name: metadata.name.clone(),
218                description: metadata.description.clone(),
219                sql,
220                data_source_id: metadata.data_source_id,
221                user: None,
222                schedule: metadata.schedule.clone(),
223                options: metadata.options.clone(),
224                visualizations: metadata.visualizations.clone(),
225                tags: metadata.tags.clone(),
226                is_archived: false,
227                is_draft: false,
228                updated_at: String::new(),
229                created_at: String::new(),
230            };
231            let result = client.create_or_update_query(&query).await?;
232            println!("  ✓ {id} - {name}");
233            result
234        };
235
236        deploy_visualizations(client, result_query.id, &metadata.visualizations).await?;
237    }
238
239    println!("\n✓ All resources deployed successfully");
240
241    Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_validate_enum_options_rejects_escaped_newlines() {
250        let metadata = crate::models::QueryMetadata {
251            id: 1,
252            name: "Test Query".to_string(),
253            description: None,
254            data_source_id: 1,
255            user_id: None,
256            schedule: None,
257            options: crate::models::QueryOptions {
258                parameters: vec![crate::models::Parameter {
259                    name: "test_param".to_string(),
260                    title: "Test Param".to_string(),
261                    param_type: "enum".to_string(),
262                    enum_options: Some("option1\\noption2\\noption3".to_string()),
263                    query_id: Some(1),
264                    value: None,
265                    multi_values_options: None,
266                }],
267            },
268            visualizations: vec![],
269            tags: None,
270        };
271
272        let result = validate_enum_options(&metadata, "test.yaml");
273        assert!(result.is_err());
274        let err_msg = result.unwrap_err().to_string();
275        assert!(err_msg.contains("escaped newlines"));
276        assert!(err_msg.contains("test_param"));
277        assert!(err_msg.contains("YAML multiline format"));
278    }
279
280    #[test]
281    fn test_validate_enum_options_accepts_multiline() {
282        let metadata = crate::models::QueryMetadata {
283            id: 1,
284            name: "Test Query".to_string(),
285            description: None,
286            data_source_id: 1,
287            user_id: None,
288            schedule: None,
289            options: crate::models::QueryOptions {
290                parameters: vec![crate::models::Parameter {
291                    name: "test_param".to_string(),
292                    title: "Test Param".to_string(),
293                    param_type: "enum".to_string(),
294                    enum_options: Some("option1\noption2\noption3".to_string()),
295                    query_id: Some(1),
296                    value: None,
297                    multi_values_options: None,
298                }],
299            },
300            visualizations: vec![],
301            tags: None,
302        };
303
304        let result = validate_enum_options(&metadata, "test.yaml");
305        assert!(result.is_ok());
306    }
307
308    #[test]
309    fn test_validate_enum_options_accepts_no_enum() {
310        let metadata = crate::models::QueryMetadata {
311            id: 1,
312            name: "Test Query".to_string(),
313            description: None,
314            data_source_id: 1,
315            user_id: None,
316            schedule: None,
317            options: crate::models::QueryOptions {
318                parameters: vec![crate::models::Parameter {
319                    name: "test_param".to_string(),
320                    title: "Test Param".to_string(),
321                    param_type: "text".to_string(),
322                    enum_options: None,
323                    query_id: Some(1),
324                    value: None,
325                    multi_values_options: None,
326                }],
327            },
328            visualizations: vec![],
329            tags: None,
330        };
331
332        let result = validate_enum_options(&metadata, "test.yaml");
333        assert!(result.is_ok());
334    }
335}