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