use crate::error::{Error, Result};
use colored::Colorize;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub struct DetectedStack {
pub has_rust: bool,
pub has_node: bool,
pub has_go: bool,
pub has_python: bool,
pub has_java: bool,
pub has_ruby: bool,
pub has_terraform: bool,
}
pub fn detect_stack(dir: &Path) -> DetectedStack {
let mut stack = DetectedStack {
has_rust: dir.join("Cargo.toml").exists(),
has_node: dir.join("package.json").exists(),
has_go: dir.join("go.mod").exists(),
has_python: dir.join("pyproject.toml").exists()
|| dir.join("setup.py").exists()
|| dir.join("requirements.txt").exists(),
has_java: dir.join("pom.xml").exists() || dir.join("build.gradle").exists(),
has_ruby: dir.join("Gemfile").exists(),
..DetectedStack::default()
};
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if let Some(ext) = entry.path().extension() {
if ext == "tf" {
stack.has_terraform = true;
break;
}
}
}
}
stack
}
pub fn build_config_toml(stack: &DetectedStack) -> String {
let mut extra_excludes: Vec<&str> = Vec::new();
if stack.has_rust {
extra_excludes.push("target/**");
}
if stack.has_node {
extra_excludes.push("node_modules/**");
extra_excludes.push("dist/**");
extra_excludes.push("build/**");
}
if stack.has_go {
extra_excludes.push("vendor/**");
}
if stack.has_python {
extra_excludes.push("__pycache__/**");
extra_excludes.push("*.pyc");
extra_excludes.push(".venv/**");
extra_excludes.push("venv/**");
}
if stack.has_java {
extra_excludes.push("target/**");
extra_excludes.push("build/**");
extra_excludes.push(".gradle/**");
}
if stack.has_ruby {
extra_excludes.push("vendor/**");
}
if stack.has_terraform {
extra_excludes.push(".terraform/**");
}
let universal_excludes = vec![".git/**", "*.min.js"];
let all_excludes: Vec<&str> = universal_excludes
.into_iter()
.chain(extra_excludes)
.collect();
let exclude_entries: String = all_excludes
.iter()
.map(|e| format!(" \"{}\",\n", e))
.collect();
format!(
r#"# timebomb configuration
# Generated by `timebomb init`
# See https://github.com/yourname/timebomb for documentation
# Triggers to scan for (case-insensitive)
triggers = ["TODO", "FIXME", "HACK", "TEMP", "REMOVEME", "DEBT", "STOPSHIP", "WORKAROUND", "DEPRECATED", "BUG"]
# Warn if a fuse expires within this many days
fuse_days = 14
# File extensions to scan
extensions = [
"rs", "go", "ts", "js", "py", "rb",
"java", "sql", "tf", "yaml", "yml",
]
# Paths to exclude
exclude = [
{exclude_entries}]
"#,
exclude_entries = exclude_entries,
)
}
pub fn print_ci_snippet(_stack: &DetectedStack) {
let divider = "─".repeat(46);
let divider_colored = divider.cyan().dimmed();
println!("{}", divider_colored);
println!("Add timebomb to your CI (GitHub Actions):");
println!();
println!(" - name: Check for expired timebombs");
println!(" uses: actions/checkout@v4");
println!();
println!(" - name: Install timebomb");
println!(" run: cargo install timebomb");
println!();
println!(" - name: Run timebomb");
println!(" run: timebomb sweep --fuse 14d");
println!();
println!("{}", divider_colored);
}
pub fn run_init(dir: &Path, yes: bool) -> Result<i32> {
let config_path: PathBuf = dir.join(".timebomb.toml");
if config_path.exists() {
eprintln!(
"warning: .timebomb.toml already exists at {}",
config_path.display()
);
if !yes {
print!("Overwrite? [y/N]: ");
io::stdout().flush().map_err(|e| Error::Io {
source: e,
path: None,
})?;
let stdin = io::stdin();
let mut line_buf = String::new();
stdin
.lock()
.read_line(&mut line_buf)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
let response = line_buf.trim();
if response != "y" && response != "Y" {
return Ok(0);
}
}
}
let stack = detect_stack(dir);
println!("Detected stacks:");
let mut any_detected = false;
if stack.has_rust {
println!(" {} Rust (Cargo.toml)", "✓".green());
any_detected = true;
}
if stack.has_node {
println!(" {} Node (package.json)", "✓".green());
any_detected = true;
}
if stack.has_go {
println!(" {} Go (go.mod)", "✓".green());
any_detected = true;
}
if stack.has_python {
println!(
" {} Python (pyproject.toml / setup.py / requirements.txt)",
"✓".green()
);
any_detected = true;
}
if stack.has_java {
println!(" {} Java (pom.xml / build.gradle)", "✓".green());
any_detected = true;
}
if stack.has_ruby {
println!(" {} Ruby (Gemfile)", "✓".green());
any_detected = true;
}
if stack.has_terraform {
println!(" {} Terraform (*.tf)", "✓".green());
any_detected = true;
}
if !any_detected {
println!(" (no known stack files found — using defaults)");
}
let toml_content = build_config_toml(&stack);
println!("\nWill write .timebomb.toml to: {}", config_path.display());
if !yes {
print!("Write? [Y/n]: ");
io::stdout().flush().map_err(|e| Error::Io {
source: e,
path: None,
})?;
let stdin = io::stdin();
let mut line_buf = String::new();
stdin
.lock()
.read_line(&mut line_buf)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
let response = line_buf.trim();
if response == "n" || response == "N" {
return Ok(0);
}
}
std::fs::write(&config_path, &toml_content).map_err(|e| Error::Io {
source: e,
path: Some(config_path.clone()),
})?;
println!("{}", "✓ Wrote .timebomb.toml".green());
print_ci_snippet(&stack);
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_detect_stack_empty_dir() {
let tmp = TempDir::new().unwrap();
let stack = detect_stack(tmp.path());
assert!(!stack.has_rust);
assert!(!stack.has_node);
assert!(!stack.has_go);
assert!(!stack.has_python);
assert!(!stack.has_java);
assert!(!stack.has_ruby);
assert!(!stack.has_terraform);
}
#[test]
fn test_detect_stack_rust() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_rust);
assert!(!stack.has_node);
assert!(!stack.has_go);
assert!(!stack.has_python);
assert!(!stack.has_java);
assert!(!stack.has_ruby);
assert!(!stack.has_terraform);
}
#[test]
fn test_detect_stack_node() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
let stack = detect_stack(tmp.path());
assert!(!stack.has_rust);
assert!(stack.has_node);
}
#[test]
fn test_detect_stack_python() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("pyproject.toml"), "[build-system]").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_python);
assert!(!stack.has_rust);
}
#[test]
fn test_detect_stack_python_requirements() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("requirements.txt"), "requests==2.0").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_python);
}
#[test]
fn test_detect_stack_go() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("go.mod"), "module example.com/foo").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_go);
assert!(!stack.has_rust);
}
#[test]
fn test_detect_stack_terraform() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("main.tf"), "provider \"aws\" {}").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_terraform);
assert!(!stack.has_rust);
}
#[test]
fn test_detect_stack_multiple() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
let stack = detect_stack(tmp.path());
assert!(stack.has_rust);
assert!(stack.has_node);
assert!(!stack.has_go);
assert!(!stack.has_python);
}
#[test]
fn test_build_config_toml_contains_triggers() {
let stack = DetectedStack::default();
let toml = build_config_toml(&stack);
assert!(
toml.contains("triggers ="),
"expected 'triggers =' in output:\n{}",
toml
);
}
#[test]
fn test_build_config_toml_contains_fuse_days() {
let stack = DetectedStack::default();
let toml = build_config_toml(&stack);
assert!(
toml.contains("fuse_days = 14"),
"expected 'fuse_days = 14' in output:\n{}",
toml
);
}
#[test]
fn test_build_config_toml_rust_excludes_target() {
let stack = DetectedStack {
has_rust: true,
..Default::default()
};
let toml = build_config_toml(&stack);
assert!(
toml.contains("\"target/**\""),
"expected '\"target/**\"' in output:\n{}",
toml
);
}
#[test]
fn test_build_config_toml_node_excludes_node_modules() {
let stack = DetectedStack {
has_node: true,
..Default::default()
};
let toml = build_config_toml(&stack);
assert!(
toml.contains("\"node_modules/**\""),
"expected '\"node_modules/**\"' in output:\n{}",
toml
);
}
#[test]
fn test_build_config_toml_python_excludes_pycache() {
let stack = DetectedStack {
has_python: true,
..Default::default()
};
let toml = build_config_toml(&stack);
assert!(
toml.contains("\"__pycache__/**\""),
"expected '\"__pycache__/**\"' in output:\n{}",
toml
);
}
#[test]
fn test_build_config_toml_is_valid_toml() {
let stack = DetectedStack {
has_rust: true,
has_node: true,
has_python: true,
..Default::default()
};
let toml_str = build_config_toml(&stack);
let parsed = toml::from_str::<toml::Value>(&toml_str);
assert!(
parsed.is_ok(),
"expected valid TOML, got error: {:?}\n\nContent:\n{}",
parsed.err(),
toml_str
);
}
#[test]
fn test_run_init_writes_file() {
let tmp = TempDir::new().unwrap();
let result = run_init(tmp.path(), true);
assert!(result.is_ok(), "run_init failed: {:?}", result.err());
assert_eq!(result.unwrap(), 0);
let config_path = tmp.path().join(".timebomb.toml");
assert!(config_path.exists(), ".timebomb.toml was not created");
let contents = fs::read_to_string(&config_path).unwrap();
let parsed = toml::from_str::<toml::Value>(&contents);
assert!(
parsed.is_ok(),
"written file is not valid TOML: {:?}\n\nContent:\n{}",
parsed.err(),
contents
);
}
#[test]
fn test_run_init_existing_file_no_overwrite() {
let tmp = TempDir::new().unwrap();
let r1 = run_init(tmp.path(), true);
assert!(r1.is_ok());
assert_eq!(r1.unwrap(), 0);
let config_path = tmp.path().join(".timebomb.toml");
assert!(config_path.exists());
let r2 = run_init(tmp.path(), true);
assert!(r2.is_ok(), "second run_init failed: {:?}", r2.err());
assert_eq!(r2.unwrap(), 0);
assert!(config_path.exists());
let contents = fs::read_to_string(&config_path).unwrap();
let parsed = toml::from_str::<toml::Value>(&contents);
assert!(parsed.is_ok(), "file is not valid TOML after overwrite");
}
#[test]
fn test_run_init_creates_correct_content() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"test\"\n",
)
.unwrap();
let result = run_init(tmp.path(), true);
assert!(result.is_ok(), "run_init failed: {:?}", result.err());
let config_path = tmp.path().join(".timebomb.toml");
let contents = fs::read_to_string(&config_path).unwrap();
assert!(
contents.contains("\"target/**\""),
"expected '\"target/**\"' in written config:\n{}",
contents
);
let parsed = toml::from_str::<toml::Value>(&contents);
assert!(parsed.is_ok(), "written file is not valid TOML");
}
}