stmo_cli/commands/
fetch.rs1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6
7use crate::api::RedashClient;
8
9fn slugify(s: &str) -> String {
10 s.to_lowercase()
11 .chars()
12 .map(|c| if c.is_alphanumeric() { c } else { '-' })
13 .collect::<String>()
14 .split('-')
15 .filter(|s| !s.is_empty())
16 .collect::<Vec<_>>()
17 .join("-")
18}
19
20fn extract_query_ids_from_directory() -> Result<Vec<u64>> {
21 let queries_dir = Path::new("queries");
22
23 if !queries_dir.exists() {
24 return Ok(Vec::new());
25 }
26
27 let mut query_ids = Vec::new();
28
29 for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
30 let entry = entry.context("Failed to read directory entry")?;
31 let path = entry.path();
32
33 if path.extension().is_some_and(|ext| ext == "yaml")
34 && let Some(filename) = path.file_name().and_then(|f| f.to_str())
35 && let Some(id_str) = filename.split('-').next()
36 && let Ok(id) = id_str.parse::<u64>()
37 {
38 query_ids.push(id);
39 }
40 }
41
42 query_ids.sort_unstable();
43 query_ids.dedup();
44
45 Ok(query_ids)
46}
47
48pub async fn fetch(client: &RedashClient, query_ids: Vec<u64>, all: bool) -> Result<()> {
49 fs::create_dir_all("queries").context("Failed to create queries directory")?;
50
51 let existing_query_ids = extract_query_ids_from_directory()?;
52
53 let queries_to_fetch = if all {
54 if existing_query_ids.is_empty() {
55 anyhow::bail!(
56 "No queries found in queries/ directory. Use specific query IDs or run 'discover' to see available queries."
57 );
58 }
59 println!(
60 "Fetching {} queries from local directory...\n",
61 existing_query_ids.len()
62 );
63 let mut queries = Vec::new();
64 for id in &existing_query_ids {
65 match client.get_query(*id).await {
66 Ok(query) => queries.push(query),
67 Err(e) => eprintln!(" ⚠ Query {id} failed to fetch: {e}"),
68 }
69 }
70 queries
71 } else if !query_ids.is_empty() {
72 println!("Fetching {} specific queries...\n", query_ids.len());
73 let mut queries = Vec::new();
74 for id in &query_ids {
75 match client.get_query(*id).await {
76 Ok(query) => queries.push(query),
77 Err(e) => eprintln!(" ⚠ Query {id} failed to fetch: {e}"),
78 }
79 }
80 queries
81 } else {
82 anyhow::bail!(
83 "No query IDs specified. Use --all to fetch tracked queries, or provide specific query IDs.\n\nExamples:\n stmo-cli fetch --all\n stmo-cli fetch 123 456 789\n stmo-cli discover (to see available queries)"
84 );
85 };
86
87 println!("Fetching {} queries...", queries_to_fetch.len());
88
89 let mut archived_queries = Vec::new();
90
91 for query in &queries_to_fetch {
92 let slug = slugify(&query.name);
93 let filename_base = format!("{}-{}", query.id, slug);
94
95 let sql_path = format!("queries/{filename_base}.sql");
96 fs::write(&sql_path, &query.sql).context(format!("Failed to write {sql_path}"))?;
97
98 let mut visualizations: Vec<crate::models::VisualizationMetadata> = query
99 .visualizations
100 .iter()
101 .map(crate::models::VisualizationMetadata::from)
102 .collect();
103 visualizations.sort_by_key(|v| v.id);
104 let metadata = crate::models::QueryMetadata {
105 id: query.id,
106 name: query.name.clone(),
107 description: query.description.clone(),
108 data_source_id: query.data_source_id,
109 user_id: query.user.as_ref().map(|u| u.id),
110 schedule: query.schedule.clone(),
111 options: query.options.clone(),
112 visualizations,
113 tags: query.tags.clone(),
114 };
115
116 let yaml_path = format!("queries/{filename_base}.yaml");
117 let yaml_content =
118 serde_yaml::to_string(&metadata).context("Failed to serialize query metadata")?;
119 fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
120
121 if query.is_archived {
122 archived_queries.push((query.id, query.name.clone()));
123 println!(" ✓ {} - {} [ARCHIVED]", query.id, query.name);
124 } else {
125 println!(" ✓ {} - {}", query.id, query.name);
126 }
127 }
128
129 println!("\n✓ All resources fetched successfully");
130
131 if !archived_queries.is_empty() {
132 println!(
133 "\n⚠ Warning: {} archived queries have local files:",
134 archived_queries.len()
135 );
136 for (id, name) in &archived_queries {
137 println!(" - {id}: {name}");
138 }
139 let binary_name = std::env::args()
140 .next()
141 .and_then(|path| {
142 std::path::Path::new(&path)
143 .file_name()
144 .map(|s| s.to_string_lossy().to_string())
145 })
146 .unwrap_or_else(|| "stmo-cli".to_string());
147 println!("\nConsider cleaning up with: {binary_name} archive --cleanup");
148 }
149
150 Ok(())
151}
152
153#[cfg(test)]
154#[allow(clippy::missing_errors_doc)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_slugify_simple() {
160 assert_eq!(slugify("Hello World"), "hello-world");
161 }
162
163 #[test]
164 fn test_slugify_special_chars() {
165 assert_eq!(slugify("Foo & Bar!"), "foo-bar");
166 assert_eq!(slugify("Test@#$%Query"), "test-query");
167 }
168
169 #[test]
170 fn test_slugify_unicode() {
171 assert_eq!(slugify("Café Münch"), "café-münch");
172 assert_eq!(slugify("日本語"), "日本語");
173 }
174
175 #[test]
176 fn test_slugify_multiple_spaces() {
177 assert_eq!(slugify("a b c"), "a-b-c");
178 assert_eq!(slugify(" leading and trailing "), "leading-and-trailing");
179 }
180
181 #[test]
182 fn test_slugify_already_slugified() {
183 assert_eq!(slugify("already-slug"), "already-slug");
184 assert_eq!(slugify("some-kebab-case"), "some-kebab-case");
185 }
186
187 #[test]
188 fn test_slugify_numbers() {
189 assert_eq!(slugify("Query 123"), "query-123");
190 assert_eq!(slugify("123-456"), "123-456");
191 }
192
193 #[test]
194 fn test_slugify_mixed() {
195 assert_eq!(slugify("Mozilla's .deb Package!"), "mozilla-s-deb-package");
196 assert_eq!(
197 slugify("Copy of 100234 - Gecko decision task"),
198 "copy-of-100234-gecko-decision-task"
199 );
200 }
201}