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