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