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    server_visualizations: &[crate::models::Visualization],
110) -> Result<()> {
111    let mut matched_server_ids: HashSet<u64> = HashSet::new();
112    for viz in visualizations {
113        if viz.id == 0 {
114            let server_match = server_visualizations
115                .iter()
116                .find(|sv| sv.viz_type == viz.viz_type && !matched_server_ids.contains(&sv.id));
117            if let Some(server_viz) = server_match {
118                matched_server_ids.insert(server_viz.id);
119                let viz_to_update = crate::models::Visualization {
120                    id: server_viz.id,
121                    name: viz.name.clone(),
122                    viz_type: viz.viz_type.clone(),
123                    options: viz.options.clone(),
124                    description: viz.description.clone(),
125                };
126                client.update_visualization(&viz_to_update).await?;
127                println!("    ✓ Updated visualization: {} (ID: {})", viz_to_update.name, server_viz.id);
128            } else {
129                let viz_to_create = crate::models::CreateVisualization {
130                    query_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                let created = client.create_visualization(query_id, &viz_to_create).await?;
137                println!("    ✓ Created visualization: {} (ID: {})", created.name, created.id);
138            }
139        } else {
140            client.update_visualization(viz).await?;
141        }
142    }
143    Ok(())
144}
145
146#[allow(clippy::too_many_lines)]
147pub async fn deploy(client: &RedashClient, query_ids: Vec<u64>, all: bool) -> Result<()> {
148    let all_queries = get_all_query_metadata()?;
149
150    let queries_to_deploy = if !query_ids.is_empty() {
151        let ids_set: HashSet<_> = query_ids.iter().copied().collect();
152        let filtered: Vec<_> = all_queries
153            .into_iter()
154            .filter(|(id, _)| ids_set.contains(id))
155            .collect();
156
157        if filtered.is_empty() {
158            bail!("None of the specified query IDs were found in queries/ directory");
159        }
160
161        println!("Deploying {} specific queries...", filtered.len());
162        for (id, name) in &filtered {
163            println!("  → {id} - {name}");
164        }
165        println!();
166
167        filtered
168    } else if all {
169        println!("Deploying all {} queries...\n", all_queries.len());
170        all_queries
171    } else {
172        let changed_ids = get_changed_query_ids()?;
173
174        if changed_ids.is_empty() {
175            println!("No changed queries detected.");
176            println!("Tip: Use --all to deploy all queries regardless of git status.");
177            return Ok(());
178        }
179
180        let filtered: Vec<_> = all_queries
181            .into_iter()
182            .filter(|(id, _)| changed_ids.contains(id))
183            .collect();
184
185        println!("Deploying {} changed queries...", filtered.len());
186        for (id, name) in &filtered {
187            println!("  → {id} - {name}");
188        }
189        println!();
190
191        filtered
192    };
193
194    for (id, name) in &queries_to_deploy {
195        let slug = slugify(name);
196        let sql_path = format!("queries/{id}-{slug}.sql");
197        let yaml_path = format!("queries/{id}-{slug}.yaml");
198
199        if !Path::new(&sql_path).exists() {
200            bail!("Query SQL file not found: {sql_path}");
201        }
202        if !Path::new(&yaml_path).exists() {
203            bail!("Query metadata file not found: {yaml_path}");
204        }
205
206        let sql = fs::read_to_string(&sql_path)
207            .context(format!("Failed to read {sql_path}"))?;
208
209        let metadata_content = fs::read_to_string(&yaml_path)
210            .context(format!("Failed to read {yaml_path}"))?;
211
212        let metadata: crate::models::QueryMetadata = serde_yaml::from_str(&metadata_content)
213            .context(format!("Failed to parse {yaml_path}"))?;
214
215        validate_enum_options(&metadata, &yaml_path)?;
216
217        let result_query = if *id == 0 {
218            let create_query = crate::models::CreateQuery {
219                name: metadata.name.clone(),
220                description: metadata.description.clone(),
221                sql,
222                data_source_id: metadata.data_source_id,
223                schedule: metadata.schedule.clone(),
224                options: Some(metadata.options.clone()),
225                tags: metadata.tags.clone(),
226                is_archived: false,
227                is_draft: false,
228            };
229            let created = client.create_query(&create_query).await?;
230            let fetched = client.get_query(created.id).await?;
231            let new_slug = slugify(&fetched.name);
232            let new_base = format!("queries/{}-{new_slug}", fetched.id);
233            fs::write(format!("{new_base}.sql"), &fetched.sql)
234                .context(format!("Failed to write {new_base}.sql"))?;
235            let mut new_visualizations = fetched.visualizations.clone();
236            new_visualizations.sort_by_key(|v| v.id);
237            let new_metadata = crate::models::QueryMetadata {
238                id: fetched.id,
239                name: fetched.name.clone(),
240                description: fetched.description.clone(),
241                data_source_id: fetched.data_source_id,
242                user_id: fetched.user.as_ref().map(|u| u.id),
243                schedule: fetched.schedule.clone(),
244                options: fetched.options.clone(),
245                visualizations: new_visualizations,
246                tags: fetched.tags.clone(),
247            };
248            let yaml_content = serde_yaml::to_string(&new_metadata)
249                .context("Failed to serialize query metadata")?;
250            fs::write(format!("{new_base}.yaml"), yaml_content)
251                .context(format!("Failed to write {new_base}.yaml"))?;
252            fs::remove_file(&sql_path)
253                .context(format!("Failed to delete {sql_path}"))?;
254            fs::remove_file(&yaml_path)
255                .context(format!("Failed to delete {yaml_path}"))?;
256            println!("  ✓ Created new query: {} - {name}", fetched.id);
257            println!("    Renamed: 0-{slug}.* → {}-{new_slug}.*", fetched.id);
258            fetched
259        } else {
260            let query = Query {
261                id: metadata.id,
262                name: metadata.name.clone(),
263                description: metadata.description.clone(),
264                sql,
265                data_source_id: metadata.data_source_id,
266                user: None,
267                schedule: metadata.schedule.clone(),
268                options: metadata.options.clone(),
269                visualizations: metadata.visualizations.clone(),
270                tags: metadata.tags.clone(),
271                is_archived: false,
272                is_draft: false,
273                updated_at: String::new(),
274                created_at: String::new(),
275            };
276            let result = client.create_or_update_query(&query).await?;
277            let fetched = client.get_query(*id).await?;
278            let mut updated_visualizations = fetched.visualizations.clone();
279            updated_visualizations.sort_by_key(|v| v.id);
280            let updated_metadata = crate::models::QueryMetadata {
281                id: fetched.id,
282                name: fetched.name.clone(),
283                description: fetched.description.clone(),
284                data_source_id: fetched.data_source_id,
285                user_id: fetched.user.as_ref().map(|u| u.id),
286                schedule: fetched.schedule.clone(),
287                options: fetched.options.clone(),
288                visualizations: updated_visualizations,
289                tags: fetched.tags.clone(),
290            };
291            let yaml_content = serde_yaml::to_string(&updated_metadata)
292                .context("Failed to serialize query metadata")?;
293            fs::write(&yaml_path, yaml_content)
294                .context(format!("Failed to write {yaml_path}"))?;
295            println!("  ✓ {id} - {name}");
296            result
297        };
298
299        deploy_visualizations(client, result_query.id, &metadata.visualizations, &result_query.visualizations).await?;
300    }
301
302    println!("\n✓ All resources deployed successfully");
303
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_validate_enum_options_rejects_escaped_newlines() {
313        let metadata = crate::models::QueryMetadata {
314            id: 1,
315            name: "Test Query".to_string(),
316            description: None,
317            data_source_id: 1,
318            user_id: None,
319            schedule: None,
320            options: crate::models::QueryOptions {
321                parameters: vec![crate::models::Parameter {
322                    name: "test_param".to_string(),
323                    title: "Test Param".to_string(),
324                    param_type: "enum".to_string(),
325                    enum_options: Some("option1\\noption2\\noption3".to_string()),
326                    query_id: Some(1),
327                    value: None,
328                    multi_values_options: None,
329                }],
330            },
331            visualizations: vec![],
332            tags: None,
333        };
334
335        let result = validate_enum_options(&metadata, "test.yaml");
336        assert!(result.is_err());
337        let err_msg = result.unwrap_err().to_string();
338        assert!(err_msg.contains("escaped newlines"));
339        assert!(err_msg.contains("test_param"));
340        assert!(err_msg.contains("YAML multiline format"));
341    }
342
343    #[test]
344    fn test_validate_enum_options_accepts_multiline() {
345        let metadata = crate::models::QueryMetadata {
346            id: 1,
347            name: "Test Query".to_string(),
348            description: None,
349            data_source_id: 1,
350            user_id: None,
351            schedule: None,
352            options: crate::models::QueryOptions {
353                parameters: vec![crate::models::Parameter {
354                    name: "test_param".to_string(),
355                    title: "Test Param".to_string(),
356                    param_type: "enum".to_string(),
357                    enum_options: Some("option1\noption2\noption3".to_string()),
358                    query_id: Some(1),
359                    value: None,
360                    multi_values_options: None,
361                }],
362            },
363            visualizations: vec![],
364            tags: None,
365        };
366
367        let result = validate_enum_options(&metadata, "test.yaml");
368        assert!(result.is_ok());
369    }
370
371    #[test]
372    fn test_validate_enum_options_accepts_no_enum() {
373        let metadata = crate::models::QueryMetadata {
374            id: 1,
375            name: "Test Query".to_string(),
376            description: None,
377            data_source_id: 1,
378            user_id: None,
379            schedule: None,
380            options: crate::models::QueryOptions {
381                parameters: vec![crate::models::Parameter {
382                    name: "test_param".to_string(),
383                    title: "Test Param".to_string(),
384                    param_type: "text".to_string(),
385                    enum_options: None,
386                    query_id: Some(1),
387                    value: None,
388                    multi_values_options: None,
389                }],
390            },
391            visualizations: vec![],
392            tags: None,
393        };
394
395        let result = validate_enum_options(&metadata, "test.yaml");
396        assert!(result.is_ok());
397    }
398}