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 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}