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) = ¶m.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}