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