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
53const KNOWN_SCAFFOLD_PATHS: &[&str] = &[
57 ".gitignore",
58 ".sqlfluff",
59 ".yamllint",
60 ".pre-commit-config.yaml",
61 "CLAUDE.md",
62];
63
64#[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
98fn 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
141fn 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
182fn 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 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
512fn 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 #[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 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 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 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 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 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}