stmo_cli/commands/
init.rs1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8const TEMPLATE_PRE_COMMIT: &str = include_str!("../../templates/init/pre-commit-config.yaml");
9const TEMPLATE_SQLFLUFF: &str = include_str!("../../templates/init/sqlfluff");
10const TEMPLATE_YAMLLINT: &str = include_str!("../../templates/init/yamllint");
11const TEMPLATE_GITIGNORE: &str = include_str!("../../templates/init/gitignore");
12const TEMPLATE_CLAUDE_MD: &str = include_str!("../../templates/init/CLAUDE.md");
13
14struct ScaffoldFile {
15 path: &'static str,
16 content: &'static str,
17 description: &'static str,
18}
19
20const SCAFFOLD_FILES: &[ScaffoldFile] = &[
21 ScaffoldFile {
22 path: ".pre-commit-config.yaml",
23 content: TEMPLATE_PRE_COMMIT,
24 description: "pre-commit hooks config",
25 },
26 ScaffoldFile {
27 path: ".sqlfluff",
28 content: TEMPLATE_SQLFLUFF,
29 description: "sqlfluff linter config",
30 },
31 ScaffoldFile {
32 path: ".yamllint",
33 content: TEMPLATE_YAMLLINT,
34 description: "yamllint config",
35 },
36 ScaffoldFile {
37 path: ".gitignore",
38 content: TEMPLATE_GITIGNORE,
39 description: "git ignore rules",
40 },
41 ScaffoldFile {
42 path: "CLAUDE.md",
43 content: TEMPLATE_CLAUDE_MD,
44 description: "AI assistant instructions",
45 },
46];
47
48fn write_if_missing(target_dir: &Path, file: &ScaffoldFile) -> Result<bool> {
49 let file_path = target_dir.join(file.path);
50
51 if file_path.exists() {
52 let path = file.path;
53 println!(" ⊘ {path} (already exists)");
54 Ok(false)
55 } else {
56 let path = file.path;
57 fs::write(&file_path, file.content)
58 .with_context(|| format!("Failed to write {path}"))?;
59 let description = file.description;
60 println!(" ✓ {path} ({description})");
61 Ok(true)
62 }
63}
64
65fn create_directory_with_gitkeep(target_dir: &Path, dir_name: &str) -> Result<bool> {
66 let dir_path = target_dir.join(dir_name);
67 let gitkeep_path = dir_path.join(".gitkeep");
68
69 if gitkeep_path.exists() {
70 println!(" ⊘ {dir_name}/ (already exists)");
71 Ok(false)
72 } else {
73 fs::create_dir_all(&dir_path)
74 .with_context(|| format!("Failed to create {dir_name} directory"))?;
75 fs::write(&gitkeep_path, "")
76 .with_context(|| format!("Failed to write {dir_name}/.gitkeep"))?;
77 println!(" ✓ {dir_name}/ (directory with .gitkeep)");
78 Ok(true)
79 }
80}
81
82fn git_available() -> bool {
83 Command::new("git")
84 .arg("--version")
85 .output()
86 .map(|output| output.status.success())
87 .unwrap_or(false)
88}
89
90fn precommit_available() -> bool {
91 Command::new("pre-commit")
92 .arg("--version")
93 .output()
94 .map(|output| output.status.success())
95 .unwrap_or(false)
96}
97
98fn detect_os() -> &'static str {
99 if cfg!(target_os = "macos") {
100 "macos"
101 } else if cfg!(target_os = "linux") {
102 "linux"
103 } else {
104 "other"
105 }
106}
107
108fn setup_git_repo(target_dir: &Path, files_created: bool) -> Result<()> {
109 let git_dir = target_dir.join(".git");
110
111 if !git_dir.exists() {
112 println!("\n⚙ Initializing git repository...");
113 let status = Command::new("git")
114 .arg("init")
115 .current_dir(target_dir)
116 .status()
117 .context("Failed to run git init")?;
118
119 if !status.success() {
120 anyhow::bail!("git init failed");
121 }
122 }
123
124 if files_created {
125 println!("⚙ Creating initial commit...");
126
127 let add_status = Command::new("git")
128 .args(["add", "."])
129 .current_dir(target_dir)
130 .status()
131 .context("Failed to run git add")?;
132
133 if !add_status.success() {
134 anyhow::bail!("git add failed");
135 }
136
137 let commit_output = Command::new("git")
138 .args(["commit", "-m", "Initial commit: scaffold query/dashboard repository"])
139 .current_dir(target_dir)
140 .output()
141 .context("Failed to run git commit")?;
142
143 if !commit_output.status.success() {
144 let stderr = String::from_utf8_lossy(&commit_output.stderr);
145 anyhow::bail!("git commit failed: {stderr}");
146 }
147
148 println!(" ✓ Initial commit created");
149 }
150
151 Ok(())
152}
153
154fn setup_precommit(target_dir: &Path) -> Result<bool> {
155 if !precommit_available() {
156 println!("\n⚠ pre-commit is not installed");
157 match detect_os() {
158 "macos" => println!(" Install with: brew install pre-commit"),
159 _ => println!(" Install with: pip install pre-commit"),
160 }
161 println!(" After installing, re-run 'stmo-cli init' to finish setup.");
162 return Ok(false);
163 }
164
165 println!("\n⚙ Setting up pre-commit...");
166
167 let autoupdate_output = Command::new("pre-commit")
168 .arg("autoupdate")
169 .current_dir(target_dir)
170 .output()
171 .context("Failed to run pre-commit autoupdate")?;
172
173 if !autoupdate_output.status.success() {
174 let stderr = String::from_utf8_lossy(&autoupdate_output.stderr);
175 anyhow::bail!("pre-commit autoupdate failed: {stderr}");
176 }
177 println!(" ✓ Updated hook versions in .pre-commit-config.yaml");
178
179 let install_output = Command::new("pre-commit")
180 .arg("install")
181 .current_dir(target_dir)
182 .output()
183 .context("Failed to run pre-commit install")?;
184
185 if !install_output.status.success() {
186 let stderr = String::from_utf8_lossy(&install_output.stderr);
187 anyhow::bail!("pre-commit install failed: {stderr}");
188 }
189 println!(" ✓ Installed pre-commit git hooks");
190
191 let amend_output = Command::new("git")
192 .args(["commit", "--amend", "--no-edit", "-a"])
193 .current_dir(target_dir)
194 .output()
195 .context("Failed to amend commit")?;
196
197 if !amend_output.status.success() {
198 let stderr = String::from_utf8_lossy(&amend_output.stderr);
199 anyhow::bail!("git commit --amend failed: {stderr}");
200 }
201 println!(" ✓ Updated initial commit with resolved hook versions");
202
203 Ok(true)
204}
205
206fn init_in(target_dir: &Path) -> Result<()> {
207 println!("Scaffolding query/dashboard repository...\n");
208
209 let mut files_created = 0;
210 let mut files_skipped = 0;
211
212 for file in SCAFFOLD_FILES {
213 if write_if_missing(target_dir, file)? {
214 files_created += 1;
215 } else {
216 files_skipped += 1;
217 }
218 }
219
220 if create_directory_with_gitkeep(target_dir, "queries")? {
221 files_created += 1;
222 } else {
223 files_skipped += 1;
224 }
225
226 if create_directory_with_gitkeep(target_dir, "dashboards")? {
227 files_created += 1;
228 } else {
229 files_skipped += 1;
230 }
231
232 println!("\n📊 Summary: {files_created} created, {files_skipped} skipped");
233
234 if files_created == 0 {
235 println!("\n✓ Repository already initialized");
236 return Ok(());
237 }
238
239 if git_available() {
240 setup_git_repo(target_dir, files_created > 0)?;
241
242 if files_created > 0 {
243 setup_precommit(target_dir)?;
244 }
245 } else {
246 println!("\n⚠ git is not installed - files created but not committed");
247 println!(" Install git to enable version control");
248 }
249
250 println!("\n✓ Repository scaffolded successfully");
251 println!("\nNext steps:");
252 println!(" 1. Set REDASH_API_KEY environment variable");
253 println!(" 2. Run 'stmo-cli discover' to see available queries");
254 println!(" 3. Run 'stmo-cli fetch <id>' to download queries");
255 println!(" 4. Run 'stmo-cli deploy' to push changes back to Redash");
256
257 Ok(())
258}
259
260pub fn init() -> Result<()> {
261 init_in(Path::new("."))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use tempfile::TempDir;
268 use std::fs;
269
270 #[test]
271 fn test_init_creates_all_files() {
272 let temp_dir = TempDir::new().unwrap();
273 init_in(temp_dir.path()).unwrap();
274
275 assert!(temp_dir.path().join(".pre-commit-config.yaml").exists());
276 assert!(temp_dir.path().join(".sqlfluff").exists());
277 assert!(temp_dir.path().join(".yamllint").exists());
278 assert!(temp_dir.path().join(".gitignore").exists());
279 assert!(temp_dir.path().join("CLAUDE.md").exists());
280 assert!(temp_dir.path().join("queries/.gitkeep").exists());
281 assert!(temp_dir.path().join("dashboards/.gitkeep").exists());
282
283 let pre_commit_content = fs::read_to_string(temp_dir.path().join(".pre-commit-config.yaml")).unwrap();
284 assert!(pre_commit_content.contains("yamllint"));
285 assert!(pre_commit_content.contains("sqlfluff"));
286
287 let sqlfluff_content = fs::read_to_string(temp_dir.path().join(".sqlfluff")).unwrap();
288 assert!(sqlfluff_content.contains("bigquery"));
289 assert!(sqlfluff_content.contains("jinja"));
290
291 let claude_md_content = fs::read_to_string(temp_dir.path().join("CLAUDE.md")).unwrap();
292 assert!(claude_md_content.contains("stmo-cli"));
293 assert!(!claude_md_content.contains("cargo run"));
294 }
295
296 #[test]
297 fn test_init_skips_existing_files() {
298 let temp_dir = TempDir::new().unwrap();
299
300 let sqlfluff_path = temp_dir.path().join(".sqlfluff");
301 fs::write(&sqlfluff_path, "custom content").unwrap();
302
303 init_in(temp_dir.path()).unwrap();
304
305 let content = fs::read_to_string(&sqlfluff_path).unwrap();
306 assert_eq!(content, "custom content");
307
308 assert!(temp_dir.path().join(".pre-commit-config.yaml").exists());
309 assert!(temp_dir.path().join("queries/.gitkeep").exists());
310 }
311
312 #[test]
313 fn test_init_creates_git_repo() {
314 let temp_dir = TempDir::new().unwrap();
315
316 if !git_available() {
317 return;
318 }
319
320 init_in(temp_dir.path()).unwrap();
321
322 assert!(temp_dir.path().join(".git").exists());
323
324 let log_output = Command::new("git")
325 .args(["log", "--oneline"])
326 .current_dir(temp_dir.path())
327 .output()
328 .unwrap();
329
330 let log = String::from_utf8_lossy(&log_output.stdout);
331 assert!(log.contains("Initial commit"));
332 }
333
334 #[test]
335 fn test_init_commits_to_existing_repo() {
336 let temp_dir = TempDir::new().unwrap();
337
338 if !git_available() {
339 return;
340 }
341
342 Command::new("git")
343 .arg("init")
344 .current_dir(temp_dir.path())
345 .status()
346 .unwrap();
347
348 fs::write(temp_dir.path().join("existing.txt"), "test").unwrap();
349 Command::new("git")
350 .args(["add", "."])
351 .current_dir(temp_dir.path())
352 .status()
353 .unwrap();
354 Command::new("git")
355 .args(["commit", "-m", "First commit"])
356 .current_dir(temp_dir.path())
357 .status()
358 .unwrap();
359
360 init_in(temp_dir.path()).unwrap();
361
362 let log_output = Command::new("git")
363 .args(["log", "--oneline"])
364 .current_dir(temp_dir.path())
365 .output()
366 .unwrap();
367
368 let log = String::from_utf8_lossy(&log_output.stdout);
369 let commit_count = log.lines().count();
370 assert!(commit_count >= 2);
371 }
372
373 #[test]
374 fn test_init_no_commit_when_all_exist() {
375 let temp_dir = TempDir::new().unwrap();
376
377 if !git_available() {
378 return;
379 }
380
381 for file in SCAFFOLD_FILES {
382 fs::write(temp_dir.path().join(file.path), file.content).unwrap();
383 }
384 fs::create_dir_all(temp_dir.path().join("queries")).unwrap();
385 fs::write(temp_dir.path().join("queries/.gitkeep"), "").unwrap();
386 fs::create_dir_all(temp_dir.path().join("dashboards")).unwrap();
387 fs::write(temp_dir.path().join("dashboards/.gitkeep"), "").unwrap();
388
389 Command::new("git")
390 .arg("init")
391 .current_dir(temp_dir.path())
392 .status()
393 .unwrap();
394 Command::new("git")
395 .args(["add", "."])
396 .current_dir(temp_dir.path())
397 .status()
398 .unwrap();
399 Command::new("git")
400 .args(["commit", "-m", "Existing commit"])
401 .current_dir(temp_dir.path())
402 .status()
403 .unwrap();
404
405 init_in(temp_dir.path()).unwrap();
406
407 let log_output = Command::new("git")
408 .args(["log", "--oneline"])
409 .current_dir(temp_dir.path())
410 .output()
411 .unwrap();
412
413 let log = String::from_utf8_lossy(&log_output.stdout);
414 let commit_count = log.lines().count();
415 assert_eq!(commit_count, 1);
416 }
417
418 #[test]
419 fn test_template_content_validity() {
420 assert!(TEMPLATE_PRE_COMMIT.contains("yamllint"));
421 assert!(TEMPLATE_PRE_COMMIT.contains("sqlfluff"));
422
423 assert!(TEMPLATE_SQLFLUFF.contains("bigquery"));
424 assert!(TEMPLATE_SQLFLUFF.contains("[sqlfluff]"));
425
426 assert!(TEMPLATE_YAMLLINT.contains("extends: default"));
427
428 assert!(TEMPLATE_GITIGNORE.contains(".DS_Store"));
429
430 assert!(TEMPLATE_CLAUDE_MD.contains("stmo-cli"));
431 assert!(TEMPLATE_CLAUDE_MD.contains("Quick Reference"));
432 }
433}