Skip to main content

stmo_cli/commands/
init.rs

1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result};
4use dialoguer::Confirm;
5use std::fs;
6use std::io::IsTerminal;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10const TEMPLATE_PRE_COMMIT: &str = include_str!("../../templates/init/pre-commit-config.yaml");
11const TEMPLATE_SQLFLUFF: &str = include_str!("../../templates/init/sqlfluff");
12const TEMPLATE_YAMLLINT: &str = include_str!("../../templates/init/yamllint");
13const TEMPLATE_GITIGNORE: &str = include_str!("../../templates/init/gitignore");
14const TEMPLATE_CLAUDE_MD: &str = include_str!("../../templates/init/CLAUDE.md");
15
16struct ScaffoldFile {
17    path: &'static str,
18    content: &'static str,
19    description: &'static str,
20}
21
22const GIT_FILES: &[ScaffoldFile] = &[ScaffoldFile {
23    path: ".gitignore",
24    content: TEMPLATE_GITIGNORE,
25    description: "git ignore rules",
26}];
27
28const LINTER_FILES: &[ScaffoldFile] = &[
29    ScaffoldFile {
30        path: ".sqlfluff",
31        content: TEMPLATE_SQLFLUFF,
32        description: "sqlfluff linter config",
33    },
34    ScaffoldFile {
35        path: ".yamllint",
36        content: TEMPLATE_YAMLLINT,
37        description: "yamllint config",
38    },
39];
40
41const PRECOMMIT_FILES: &[ScaffoldFile] = &[ScaffoldFile {
42    path: ".pre-commit-config.yaml",
43    content: TEMPLATE_PRE_COMMIT,
44    description: "pre-commit hooks config",
45}];
46
47const CLAUDE_MD_FILE: ScaffoldFile = ScaffoldFile {
48    path: "CLAUDE.md",
49    content: TEMPLATE_CLAUDE_MD,
50    description: "AI assistant instructions",
51};
52
53// Every filename `init` might ever write, across all choice combinations —
54// used to recognize an existing scaffold regardless of which features a past
55// run opted into.
56const KNOWN_SCAFFOLD_PATHS: &[&str] = &[
57    ".gitignore",
58    ".sqlfluff",
59    ".yamllint",
60    ".pre-commit-config.yaml",
61    "CLAUDE.md",
62];
63
64// Five independent yes/no wizard answers, not state-machine states — a
65// state machine or nested enums would model relationships that don't exist
66// here (e.g. `linters` and `claude_md` are fully orthogonal to each other).
67#[allow(clippy::struct_excessive_bools)]
68pub struct InitChoices {
69    pub git: bool,
70    pub commit: bool,
71    pub linters: bool,
72    pub precommit: bool,
73    pub claude_md: bool,
74}
75
76#[derive(Debug)]
77struct Summary {
78    files_created: usize,
79    committed: bool,
80}
81
82fn write_if_missing(target_dir: &Path, file: &ScaffoldFile) -> Result<bool> {
83    let file_path = target_dir.join(file.path);
84
85    if file_path.exists() {
86        let path = file.path;
87        println!("  ⊘ {path} (already exists)");
88        Ok(false)
89    } else {
90        let path = file.path;
91        fs::write(&file_path, file.content).with_context(|| format!("Failed to write {path}"))?;
92        let description = file.description;
93        println!("  ✓ {path} ({description})");
94        Ok(true)
95    }
96}
97
98// Idempotence is keyed on `.gitkeep` when git is involved (so a later `init`
99// run that turns git on retroactively adds the marker) and on the directory
100// itself otherwise, since a git-less scaffold never writes `.gitkeep`.
101fn create_directory(target_dir: &Path, dir_name: &str, with_gitkeep: bool) -> Result<bool> {
102    let dir_path = target_dir.join(dir_name);
103    let already_exists = if with_gitkeep {
104        dir_path.join(".gitkeep").exists()
105    } else {
106        dir_path.exists()
107    };
108
109    if already_exists {
110        println!("  ⊘ {dir_name}/  (already exists)");
111        return Ok(false);
112    }
113
114    fs::create_dir_all(&dir_path)
115        .with_context(|| format!("Failed to create {dir_name} directory"))?;
116
117    if with_gitkeep {
118        fs::write(dir_path.join(".gitkeep"), "")
119            .with_context(|| format!("Failed to write {dir_name}/.gitkeep"))?;
120        println!("  ✓ {dir_name}/  (directory with .gitkeep)");
121    } else {
122        println!("  ✓ {dir_name}/");
123    }
124    Ok(true)
125}
126
127fn git_available() -> bool {
128    clean_git_cmd()
129        .arg("--version")
130        .output()
131        .is_ok_and(|output| output.status.success())
132}
133
134fn precommit_available() -> bool {
135    Command::new("pre-commit")
136        .arg("--version")
137        .output()
138        .is_ok_and(|output| output.status.success())
139}
140
141// Returns a git Command with inherited git env vars cleared, so commands run in
142// a fresh directory are not affected by a parent worktree's GIT_DIR or GIT_INDEX_FILE.
143fn clean_git_cmd() -> Command {
144    let mut cmd = Command::new("git");
145    cmd.env_remove("GIT_DIR")
146        .env_remove("GIT_WORK_TREE")
147        .env_remove("GIT_COMMON_DIR")
148        .env_remove("GIT_INDEX_FILE");
149    cmd
150}
151
152fn ensure_git_identity(target_dir: &Path) -> Result<()> {
153    let name_configured = clean_git_cmd()
154        .args(["config", "user.name"])
155        .current_dir(target_dir)
156        .output()
157        .is_ok_and(|o| o.status.success() && !o.stdout.trim_ascii().is_empty());
158
159    if !name_configured {
160        let set_name = clean_git_cmd()
161            .args(["config", "user.name", "stmo-cli"])
162            .current_dir(target_dir)
163            .status()
164            .context("Failed to set git user.name")?;
165        if !set_name.success() {
166            anyhow::bail!("git config user.name failed");
167        }
168
169        let set_email = clean_git_cmd()
170            .args(["config", "user.email", "stmo-cli@noreply"])
171            .current_dir(target_dir)
172            .status()
173            .context("Failed to set git user.email")?;
174        if !set_email.success() {
175            anyhow::bail!("git config user.email failed");
176        }
177    }
178
179    Ok(())
180}
181
182// The shipped .pre-commit-config.yaml template has empty `rev: ""` fields for
183// both hooks (see templates/init/pre-commit-config.yaml) — pre-commit refuses
184// to run with an unresolved rev, so a config that never got autoupdated is
185// broken, not merely out of date. Fatal, since pre-commit was explicitly opted
186// into.
187fn precommit_autoupdate(target_dir: &Path) -> Result<()> {
188    let output = Command::new("pre-commit")
189        .arg("autoupdate")
190        .current_dir(target_dir)
191        .output()
192        .context("Failed to run pre-commit autoupdate")?;
193
194    if !output.status.success() {
195        let stderr = String::from_utf8_lossy(&output.stderr);
196        anyhow::bail!(
197            "pre-commit autoupdate failed: {stderr}\n.pre-commit-config.yaml still has empty \
198             `rev:` fields and won't run. The directory at {} was already scaffolded — run \
199             'pre-commit autoupdate' there yourself.",
200            target_dir.display()
201        );
202    }
203    println!("  ✓ Updated hook versions in .pre-commit-config.yaml");
204    Ok(())
205}
206
207fn install_precommit_hooks(target_dir: &Path) -> Result<()> {
208    let install_output = Command::new("pre-commit")
209        .arg("install")
210        .current_dir(target_dir)
211        .output()
212        .context("Failed to run pre-commit install")?;
213
214    if !install_output.status.success() {
215        let stderr = String::from_utf8_lossy(&install_output.stderr);
216        anyhow::bail!(
217            "pre-commit install failed: {stderr}\nThe directory at {} was already scaffolded — \
218             run 'pre-commit install' there yourself.",
219            target_dir.display()
220        );
221    }
222    println!("  ✓ Installed pre-commit git hooks");
223    Ok(())
224}
225
226fn init_git_repo(target_dir: &Path) -> Result<bool> {
227    let git_dir = target_dir.join(".git");
228    if git_dir.exists() {
229        return Ok(false);
230    }
231
232    println!("\n⚙ Initializing git repository...");
233    let status = clean_git_cmd()
234        .arg("init")
235        .current_dir(target_dir)
236        .status()
237        .context("Failed to run git init")?;
238
239    if !status.success() {
240        anyhow::bail!("git init failed");
241    }
242    Ok(true)
243}
244
245fn create_initial_commit(target_dir: &Path) -> Result<bool> {
246    ensure_git_identity(target_dir)?;
247
248    println!("⚙ Creating initial commit...");
249
250    let add_status = clean_git_cmd()
251        .args(["add", "."])
252        .current_dir(target_dir)
253        .status()
254        .context("Failed to run git add")?;
255
256    if !add_status.success() {
257        anyhow::bail!("git add failed");
258    }
259
260    let commit_output = clean_git_cmd()
261        .args([
262            "commit",
263            "-m",
264            "Initial commit: scaffold query/dashboard repository",
265        ])
266        .current_dir(target_dir)
267        .output()
268        .context("Failed to run git commit")?;
269
270    if !commit_output.status.success() {
271        let stderr = String::from_utf8_lossy(&commit_output.stderr);
272        anyhow::bail!(
273            "git commit failed: {stderr}\nThe directory at {} was already scaffolded — \
274             commit manually when ready.",
275            target_dir.display()
276        );
277    }
278
279    println!("  ✓ Initial commit created");
280    Ok(true)
281}
282
283fn scaffold(target_dir: &Path, choices: &InitChoices) -> Result<Summary> {
284    println!("Scaffolding query/dashboard repository...\n");
285
286    let mut files_created = 0;
287
288    if create_directory(target_dir, "queries", choices.git)? {
289        files_created += 1;
290    }
291    if create_directory(target_dir, "dashboards", choices.git)? {
292        files_created += 1;
293    }
294
295    if choices.git {
296        for file in GIT_FILES {
297            if write_if_missing(target_dir, file)? {
298                files_created += 1;
299            }
300        }
301    }
302    if choices.linters {
303        for file in LINTER_FILES {
304            if write_if_missing(target_dir, file)? {
305                files_created += 1;
306            }
307        }
308    }
309    // A pre-commit config is only meaningful alongside a git repo (hooks live
310    // under `.git/hooks/`), so this stays nested under `choices.git` even
311    // though `prompt_choices` already never offers `precommit` without it.
312    if choices.git && choices.precommit {
313        for file in PRECOMMIT_FILES {
314            if write_if_missing(target_dir, file)? {
315                files_created += 1;
316            }
317        }
318    }
319    if choices.claude_md && write_if_missing(target_dir, &CLAUDE_MD_FILE)? {
320        files_created += 1;
321    }
322
323    println!("\n📊 Summary: {files_created} item(s) created");
324
325    let mut committed = false;
326
327    if choices.git {
328        if !git_available() {
329            anyhow::bail!(
330                "git was requested, but the `git` binary was not found on PATH.\n\
331                 Files were scaffolded at {}; install git and run 'git init' there yourself.",
332                target_dir.display()
333            );
334        }
335
336        init_git_repo(target_dir)?;
337
338        if choices.precommit {
339            if !precommit_available() {
340                anyhow::bail!(
341                    "pre-commit hooks were requested, but the `pre-commit` binary was not found \
342                     on PATH.\nFiles were scaffolded at {}; install pre-commit and run \
343                     'pre-commit install' there yourself.",
344                    target_dir.display()
345                );
346            }
347            println!("\n⚙ Setting up pre-commit...");
348            precommit_autoupdate(target_dir)?;
349            println!("\n⚙ Installing pre-commit hooks...");
350            install_precommit_hooks(target_dir)?;
351        }
352
353        if choices.commit && files_created > 0 {
354            committed = create_initial_commit(target_dir)?;
355        }
356    }
357
358    Ok(Summary {
359        files_created,
360        committed,
361    })
362}
363
364#[derive(Debug, PartialEq)]
365enum TargetState {
366    New,
367    Empty,
368    ExistingScaffold,
369    Unrelated(Vec<String>),
370}
371
372fn tolerated_entry(name: &str) -> bool {
373    if KNOWN_SCAFFOLD_PATHS.contains(&name) {
374        return true;
375    }
376    matches!(
377        name,
378        "queries" | "dashboards" | "snippets" | ".git" | ".github" | ".DS_Store"
379    ) || name.starts_with("README")
380        || name.starts_with("LICENSE")
381}
382
383fn classify_target(target: &Path) -> Result<TargetState> {
384    if !target.exists() {
385        return Ok(TargetState::New);
386    }
387
388    let mut entries = Vec::new();
389    for entry in fs::read_dir(target).with_context(|| {
390        format!(
391            "Failed to read contents of target directory {}",
392            target.display()
393        )
394    })? {
395        let entry = entry.context("Failed to read directory entry")?;
396        entries.push(entry.file_name().to_string_lossy().into_owned());
397    }
398
399    if entries.is_empty() {
400        return Ok(TargetState::Empty);
401    }
402
403    let unrelated: Vec<String> = entries
404        .into_iter()
405        .filter(|name| !tolerated_entry(name))
406        .collect();
407
408    if unrelated.is_empty() {
409        Ok(TargetState::ExistingScaffold)
410    } else {
411        Ok(TargetState::Unrelated(unrelated))
412    }
413}
414
415fn check_target(target: &Path) -> Result<TargetState> {
416    let state = classify_target(target)?;
417    if let TargetState::Unrelated(mut entries) = state {
418        entries.sort();
419        let shown_count = entries.len().min(5);
420        let mut names = entries[..shown_count].join(", ");
421        if entries.len() > shown_count {
422            names = format!("{names}, and {} more", entries.len() - shown_count);
423        }
424        anyhow::bail!(
425            "{} contains unrelated files ({names}) and doesn't look like a query/dashboard \
426             repository.\nPick an empty or dedicated subdirectory instead, e.g.:\n  \
427             stmo-cli init {}/stmo-queries",
428            target.display(),
429            target.display()
430        );
431    }
432    Ok(state)
433}
434
435fn prompt_choices() -> Result<InitChoices> {
436    let git = Confirm::new()
437        .with_prompt("Initialize a git repository?")
438        .default(false)
439        .interact()?;
440
441    let commit = git
442        && Confirm::new()
443            .with_prompt("Create an initial commit?")
444            .default(false)
445            .interact()?;
446
447    let linters = Confirm::new()
448        .with_prompt("Add linter configs (.sqlfluff, .yamllint)?")
449        .default(true)
450        .interact()?;
451
452    let precommit = git
453        && linters
454        && precommit_available()
455        && Confirm::new()
456            .with_prompt("Install pre-commit hooks?")
457            .default(false)
458            .interact()?;
459
460    let claude_md = Confirm::new()
461        .with_prompt("Add CLAUDE.md for AI assistants?")
462        .default(true)
463        .interact()?;
464
465    Ok(InitChoices {
466        git,
467        commit,
468        linters,
469        precommit,
470        claude_md,
471    })
472}
473
474fn print_next_steps(target_dir: &Path, choices: &InitChoices, summary: &Summary) {
475    if summary.files_created == 0 {
476        println!("\n✓ Repository already initialized");
477        return;
478    }
479
480    println!("\n✓ Repository scaffolded successfully");
481
482    if choices.git && !summary.committed {
483        if choices.commit {
484            println!("  (nothing new to commit)");
485        } else {
486            println!(
487                "  Nothing committed. To commit: git -C {} add . && git -C {} commit -m \"Initial commit\"",
488                target_dir.display(),
489                target_dir.display()
490            );
491        }
492    } else if !choices.git {
493        println!(
494            "  Tip: to version these files, run: git -C {} init && git -C {} add . && \
495             git -C {} commit -m \"Initial commit\"",
496            target_dir.display(),
497            target_dir.display(),
498            target_dir.display()
499        );
500    }
501
502    println!("\nNext steps:");
503    if target_dir != Path::new(".") {
504        println!("  0. cd {}", target_dir.display());
505    }
506    println!("  1. Set REDASH_API_KEY environment variable");
507    println!("  2. Run 'stmo-cli discover' to see available queries");
508    println!("  3. Run 'stmo-cli fetch <id>' to download queries");
509    println!("  4. Run 'stmo-cli deploy' to push changes back to Redash");
510}
511
512// A bare `stmo-cli init` (no PATH argument) scaffolds the current directory.
513fn resolve_target(path: Option<PathBuf>) -> PathBuf {
514    path.unwrap_or_else(|| PathBuf::from("."))
515}
516
517fn init_impl(
518    path: Option<PathBuf>,
519    is_terminal: impl Fn() -> bool,
520    prompt: impl FnOnce() -> Result<InitChoices>,
521) -> Result<()> {
522    let target = resolve_target(path);
523    check_target(&target)?;
524
525    if !is_terminal() {
526        anyhow::bail!(
527            "stmo-cli init needs a terminal to ask what to set up.\nRun it yourself in your \
528             own terminal, e.g.:\n  stmo-cli init {}",
529            target.display()
530        );
531    }
532
533    let choices = prompt()?;
534
535    fs::create_dir_all(&target)
536        .with_context(|| format!("Failed to create target directory {}", target.display()))?;
537
538    let summary = scaffold(&target, &choices)?;
539    print_next_steps(&target, &choices, &summary);
540
541    Ok(())
542}
543
544pub fn init(path: Option<PathBuf>) -> Result<()> {
545    init_impl(path, || std::io::stdin().is_terminal(), prompt_choices)
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use std::fs;
552    use tempfile::TempDir;
553
554    fn clean_git(dir: &std::path::Path) -> Command {
555        let mut cmd = clean_git_cmd();
556        cmd.current_dir(dir);
557        cmd
558    }
559
560    fn setup_test_repo(dir: &std::path::Path) {
561        clean_git(dir).arg("init").status().unwrap();
562        clean_git(dir)
563            .args(["config", "user.name", "Test"])
564            .status()
565            .unwrap();
566        clean_git(dir)
567            .args(["config", "user.email", "test@test"])
568            .status()
569            .unwrap();
570    }
571
572    fn commit_count(dir: &std::path::Path) -> usize {
573        let log_output = clean_git(dir).args(["log", "--oneline"]).output().unwrap();
574        String::from_utf8_lossy(&log_output.stdout).lines().count()
575    }
576
577    fn all_choices() -> InitChoices {
578        InitChoices {
579            git: true,
580            commit: true,
581            linters: true,
582            precommit: false,
583            claude_md: true,
584        }
585    }
586
587    fn no_choices() -> InitChoices {
588        InitChoices {
589            git: false,
590            commit: false,
591            linters: false,
592            precommit: false,
593            claude_md: false,
594        }
595    }
596
597    #[test]
598    fn test_resolve_target_defaults_to_dot() {
599        assert_eq!(resolve_target(None), PathBuf::from("."));
600    }
601
602    #[test]
603    fn test_resolve_target_uses_given_path() {
604        let path = PathBuf::from("/tmp/somewhere");
605        assert_eq!(resolve_target(Some(path.clone())), path);
606    }
607
608    // These two tests exercise the target-resolution behavior that `init()`
609    // adds (create the directory if missing, scaffold there instead of the
610    // cwd) via `scaffold` directly, so they don't depend on the `pre-commit`
611    // binary at all. `init()` now requires a real terminal (the wizard needs
612    // one to prompt in), so calling the full public entry point with no PATH
613    // can no longer be exercised from a non-interactive test process at all —
614    // see the interactive check in the plan instead.
615    #[test]
616    fn test_init_creates_missing_target_directory() {
617        let temp_dir = TempDir::new().unwrap();
618        let target = temp_dir.path().join("new");
619        assert!(!target.exists());
620
621        fs::create_dir_all(&target).unwrap();
622        scaffold(&target, &no_choices()).unwrap();
623
624        assert!(target.join("queries").exists());
625    }
626
627    #[test]
628    fn test_init_scaffolds_into_given_path_not_cwd() {
629        let temp_dir = TempDir::new().unwrap();
630        let cwd_marker = temp_dir.path().join("cwd-marker");
631        fs::create_dir_all(&cwd_marker).unwrap();
632        let target = temp_dir.path().join("target");
633
634        let mut choices = no_choices();
635        choices.claude_md = true;
636        fs::create_dir_all(&target).unwrap();
637        scaffold(&target, &choices).unwrap();
638
639        assert!(target.join("CLAUDE.md").exists());
640        assert!(!cwd_marker.join("CLAUDE.md").exists());
641        assert!(!cwd_marker.join("queries").exists());
642    }
643
644    #[test]
645    fn test_scaffold_all_declined_creates_only_directories() {
646        let temp_dir = TempDir::new().unwrap();
647        let target = temp_dir.path();
648
649        scaffold(target, &no_choices()).unwrap();
650
651        assert!(target.join("queries").exists());
652        assert!(target.join("dashboards").exists());
653        assert!(!target.join("queries/.gitkeep").exists());
654        assert!(!target.join(".gitignore").exists());
655        assert!(!target.join(".sqlfluff").exists());
656        assert!(!target.join(".yamllint").exists());
657        assert!(!target.join(".pre-commit-config.yaml").exists());
658        assert!(!target.join("CLAUDE.md").exists());
659        assert!(!target.join(".git").exists());
660    }
661
662    #[test]
663    fn test_scaffold_linters_only() {
664        let temp_dir = TempDir::new().unwrap();
665        let target = temp_dir.path();
666
667        let mut choices = no_choices();
668        choices.linters = true;
669        scaffold(target, &choices).unwrap();
670
671        assert!(target.join(".sqlfluff").exists());
672        assert!(target.join(".yamllint").exists());
673        assert!(!target.join(".gitignore").exists());
674        assert!(!target.join("CLAUDE.md").exists());
675        assert!(!target.join(".pre-commit-config.yaml").exists());
676    }
677
678    #[test]
679    fn test_scaffold_claude_md_only() {
680        let temp_dir = TempDir::new().unwrap();
681        let target = temp_dir.path();
682
683        let mut choices = no_choices();
684        choices.claude_md = true;
685        scaffold(target, &choices).unwrap();
686
687        assert!(target.join("CLAUDE.md").exists());
688        assert!(!target.join(".sqlfluff").exists());
689    }
690
691    #[test]
692    fn test_scaffold_precommit_without_git_writes_nothing_precommit_related() {
693        let temp_dir = TempDir::new().unwrap();
694        let target = temp_dir.path();
695
696        // `precommit: true` with `git: false` can't happen through the real
697        // wizard (`prompt_choices` only offers it when git was chosen), but
698        // `scaffold` must still not write a dangling pre-commit config for it.
699        let choices = InitChoices {
700            git: false,
701            commit: false,
702            linters: true,
703            precommit: true,
704            claude_md: false,
705        };
706        scaffold(target, &choices).unwrap();
707
708        assert!(!target.join(".pre-commit-config.yaml").exists());
709        assert!(!target.join(".git").exists());
710    }
711
712    #[test]
713    fn test_scaffold_git_without_commit_creates_repo_with_zero_commits() {
714        let temp_dir = TempDir::new().unwrap();
715        let target = temp_dir.path();
716
717        if !git_available() {
718            return;
719        }
720
721        let mut choices = no_choices();
722        choices.git = true;
723        scaffold(target, &choices).unwrap();
724
725        assert!(target.join(".git").exists());
726        assert!(target.join(".gitignore").exists());
727        assert!(target.join("queries/.gitkeep").exists());
728        assert_eq!(commit_count(target), 0);
729    }
730
731    #[test]
732    fn test_scaffold_git_and_commit_creates_exactly_one_commit() {
733        let temp_dir = TempDir::new().unwrap();
734        let target = temp_dir.path();
735
736        if !git_available() {
737            return;
738        }
739
740        scaffold(target, &all_choices()).unwrap();
741
742        assert_eq!(commit_count(target), 1);
743    }
744
745    #[test]
746    fn test_scaffold_rerun_does_not_amend() {
747        let temp_dir = TempDir::new().unwrap();
748        let target = temp_dir.path();
749
750        if !git_available() {
751            return;
752        }
753
754        scaffold(target, &all_choices()).unwrap();
755        assert_eq!(commit_count(target), 1);
756
757        // Nothing new to scaffold the second time, so nothing new to commit.
758        scaffold(target, &all_choices()).unwrap();
759        assert_eq!(commit_count(target), 1);
760    }
761
762    #[test]
763    fn test_scaffold_git_requested_but_unavailable_is_fatal() {
764        let temp_dir = TempDir::new().unwrap();
765        let target = temp_dir.path();
766
767        if git_available() {
768            return;
769        }
770
771        let mut choices = no_choices();
772        choices.git = true;
773        assert!(scaffold(target, &choices).is_err());
774    }
775
776    #[test]
777    fn test_scaffold_commit_rejected_by_hook_is_fatal_and_names_scaffolded_dir() {
778        let temp_dir = TempDir::new().unwrap();
779        let target = temp_dir.path();
780
781        if !git_available() {
782            return;
783        }
784
785        setup_test_repo(target);
786        let hooks_dir = target.join(".git/hooks");
787        fs::create_dir_all(&hooks_dir).unwrap();
788        let hook_path = hooks_dir.join("pre-commit");
789        fs::write(&hook_path, "#!/bin/sh\nexit 1\n").unwrap();
790        #[cfg(unix)]
791        {
792            use std::os::unix::fs::PermissionsExt;
793            let mut perms = fs::metadata(&hook_path).unwrap().permissions();
794            perms.set_mode(0o755);
795            fs::set_permissions(&hook_path, perms).unwrap();
796        }
797
798        let err = scaffold(target, &all_choices()).unwrap_err();
799        let message = err.to_string();
800        assert!(message.contains("git commit failed"));
801        assert!(message.contains(&target.display().to_string()));
802
803        // The scaffold files were still written even though the commit failed.
804        assert!(target.join("CLAUDE.md").exists());
805    }
806
807    #[test]
808    fn test_scaffold_precommit_autoupdate_failure_is_fatal() {
809        let temp_dir = TempDir::new().unwrap();
810        let target = temp_dir.path();
811
812        if !git_available() || !precommit_available() {
813            return;
814        }
815
816        setup_test_repo(target);
817        // Pre-seed an invalid config so `write_if_missing` leaves it alone and
818        // `pre-commit autoupdate` fails deterministically, regardless of
819        // network access.
820        fs::write(target.join(".pre-commit-config.yaml"), "not: [valid, yaml").unwrap();
821
822        let mut choices = no_choices();
823        choices.git = true;
824        choices.precommit = true;
825
826        let err = scaffold(target, &choices).unwrap_err();
827        let message = err.to_string();
828        assert!(message.contains("autoupdate failed"));
829        assert!(message.contains("rev"));
830    }
831
832    #[test]
833    fn test_scaffold_precommit_not_offered_skips_autoupdate_and_stays_ok() {
834        let temp_dir = TempDir::new().unwrap();
835        let target = temp_dir.path();
836
837        if !git_available() {
838            return;
839        }
840
841        // Same invalid config, but `precommit: false` — scaffold must not
842        // even look at it.
843        setup_test_repo(target);
844        fs::write(target.join(".pre-commit-config.yaml"), "not: [valid, yaml").unwrap();
845
846        let mut choices = no_choices();
847        choices.git = true;
848        assert!(scaffold(target, &choices).is_ok());
849    }
850
851    #[test]
852    fn test_init_impl_unrelated_directory_wins_over_tty_check() {
853        let temp_dir = TempDir::new().unwrap();
854        let target = temp_dir.path().join("home");
855        fs::create_dir_all(target.join("Documents")).unwrap();
856        fs::write(target.join(".zshrc"), "").unwrap();
857
858        let err =
859            init_impl(Some(target), || false, || panic!("prompt should not run")).unwrap_err();
860        assert!(err.to_string().contains("unrelated files"));
861    }
862
863    #[test]
864    fn test_init_impl_refuses_without_terminal() {
865        let temp_dir = TempDir::new().unwrap();
866        let target = temp_dir.path().join("repo");
867
868        let err =
869            init_impl(Some(target), || false, || panic!("prompt should not run")).unwrap_err();
870        assert!(err.to_string().contains("terminal"));
871    }
872
873    #[test]
874    fn test_init_impl_declining_everything_creates_only_directories() {
875        let temp_dir = TempDir::new().unwrap();
876        let target = temp_dir.path().join("repo");
877
878        init_impl(Some(target.clone()), || true, || Ok(no_choices())).unwrap();
879
880        assert!(target.join("queries").exists());
881        assert!(target.join("dashboards").exists());
882        assert!(!target.join(".git").exists());
883        assert!(!target.join("CLAUDE.md").exists());
884    }
885
886    #[test]
887    fn test_init_impl_full_flow_with_git_and_commit() {
888        let temp_dir = TempDir::new().unwrap();
889        let target = temp_dir.path().join("repo");
890
891        if !git_available() {
892            return;
893        }
894
895        init_impl(Some(target.clone()), || true, || Ok(all_choices())).unwrap();
896
897        assert!(target.join(".git").exists());
898        assert!(target.join("CLAUDE.md").exists());
899        assert_eq!(commit_count(&target), 1);
900    }
901
902    #[test]
903    fn test_template_content_validity() {
904        assert!(TEMPLATE_PRE_COMMIT.contains("yamllint"));
905        assert!(TEMPLATE_PRE_COMMIT.contains("sqlfluff"));
906        assert!(TEMPLATE_PRE_COMMIT.contains("sqlfluff-lint-snippets"));
907        assert!(TEMPLATE_PRE_COMMIT.contains("exclude: ^snippets/"));
908
909        assert!(TEMPLATE_SQLFLUFF.contains("bigquery"));
910        assert!(TEMPLATE_SQLFLUFF.contains("[sqlfluff]"));
911
912        assert!(TEMPLATE_YAMLLINT.contains("extends: default"));
913
914        assert!(TEMPLATE_GITIGNORE.contains(".DS_Store"));
915
916        assert!(TEMPLATE_CLAUDE_MD.contains("stmo-cli"));
917        assert!(TEMPLATE_CLAUDE_MD.contains("Quick Reference"));
918        assert!(TEMPLATE_CLAUDE_MD.contains("snippets"));
919    }
920
921    #[test]
922    fn test_classify_new_directory() {
923        let temp_dir = TempDir::new().unwrap();
924        let target = temp_dir.path().join("does-not-exist-yet");
925        assert_eq!(classify_target(&target).unwrap(), TargetState::New);
926    }
927
928    #[test]
929    fn test_classify_empty_directory() {
930        let temp_dir = TempDir::new().unwrap();
931        assert_eq!(
932            classify_target(temp_dir.path()).unwrap(),
933            TargetState::Empty
934        );
935    }
936
937    #[test]
938    fn test_classify_existing_scaffold() {
939        let temp_dir = TempDir::new().unwrap();
940        fs::create_dir_all(temp_dir.path().join("queries")).unwrap();
941        fs::write(temp_dir.path().join("queries/.gitkeep"), "").unwrap();
942        assert_eq!(
943            classify_target(temp_dir.path()).unwrap(),
944            TargetState::ExistingScaffold
945        );
946    }
947
948    #[test]
949    fn test_classify_tolerates_bare_git_repo() {
950        let temp_dir = TempDir::new().unwrap();
951        fs::create_dir_all(temp_dir.path().join(".git")).unwrap();
952        fs::write(temp_dir.path().join("README.md"), "# hi").unwrap();
953        assert_eq!(
954            classify_target(temp_dir.path()).unwrap(),
955            TargetState::ExistingScaffold
956        );
957    }
958
959    #[test]
960    fn test_classify_rejects_unrelated_files() {
961        let temp_dir = TempDir::new().unwrap();
962        fs::create_dir_all(temp_dir.path().join("Documents")).unwrap();
963        fs::write(temp_dir.path().join(".zshrc"), "").unwrap();
964
965        let TargetState::Unrelated(entries) = classify_target(temp_dir.path()).unwrap() else {
966            panic!("expected Unrelated");
967        };
968        assert!(entries.contains(&"Documents".to_string()));
969        assert!(entries.contains(&".zshrc".to_string()));
970    }
971
972    #[test]
973    fn test_classify_rejects_editor_directories() {
974        let temp_dir = TempDir::new().unwrap();
975        fs::create_dir_all(temp_dir.path().join(".vscode")).unwrap();
976
977        let TargetState::Unrelated(entries) = classify_target(temp_dir.path()).unwrap() else {
978            panic!("expected Unrelated");
979        };
980        assert_eq!(entries, vec![".vscode".to_string()]);
981    }
982
983    #[test]
984    fn test_classify_target_is_a_file() {
985        let temp_dir = TempDir::new().unwrap();
986        let target = temp_dir.path().join("not-a-directory");
987        fs::write(&target, "hi").unwrap();
988
989        assert!(classify_target(&target).is_err());
990    }
991
992    #[test]
993    fn test_check_target_bails_on_unrelated_directory() {
994        let temp_dir = TempDir::new().unwrap();
995        fs::create_dir_all(temp_dir.path().join("Documents")).unwrap();
996        fs::write(temp_dir.path().join(".zshrc"), "").unwrap();
997
998        let err = check_target(temp_dir.path()).unwrap_err();
999        let message = err.to_string();
1000        assert!(message.contains("Documents"));
1001        assert!(message.contains(".zshrc"));
1002    }
1003
1004    #[test]
1005    fn test_init_refuses_unrelated_directory() {
1006        let temp_dir = TempDir::new().unwrap();
1007        let target = temp_dir.path().join("home");
1008        fs::create_dir_all(target.join("Documents")).unwrap();
1009        fs::write(target.join(".zshrc"), "").unwrap();
1010
1011        assert!(init(Some(target.clone())).is_err());
1012
1013        assert!(!target.join(".git").exists());
1014        assert!(!target.join(".pre-commit-config.yaml").exists());
1015        assert!(!target.join("queries").exists());
1016    }
1017
1018    #[test]
1019    fn test_init_allows_rerun_in_existing_scaffold() {
1020        let temp_dir = TempDir::new().unwrap();
1021        for path in KNOWN_SCAFFOLD_PATHS {
1022            fs::write(temp_dir.path().join(path), "existing content").unwrap();
1023        }
1024        fs::create_dir_all(temp_dir.path().join("queries")).unwrap();
1025        fs::write(temp_dir.path().join("queries/.gitkeep"), "").unwrap();
1026        fs::create_dir_all(temp_dir.path().join("dashboards")).unwrap();
1027        fs::write(temp_dir.path().join("dashboards/.gitkeep"), "").unwrap();
1028
1029        assert!(check_target(temp_dir.path()).is_ok());
1030    }
1031}