Skip to main content

stmo_cli/commands/
init.rs

1#![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
95// Returns a git Command with inherited git env vars cleared, so commands run in
96// a fresh directory are not affected by a parent worktree's GIT_DIR or GIT_INDEX_FILE.
97fn 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}