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