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)
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}