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