use insta::assert_snapshot;
use std::fs;
use tempfile::TempDir;
use worktrunk::config::Approvals;
use worktrunk::config::UserConfig;
#[test]
fn test_approval_saves_to_disk() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("worktrunk").join("approvals.toml");
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/test/repo".to_string(),
"test command".to_string(),
Some(&approvals_path),
)
.unwrap();
assert!(
approvals_path.exists(),
"Approvals file was not created at {:?}",
approvals_path
);
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert_snapshot!(toml_content, @r#"
[projects."github.com/test/repo"]
approved-commands = [
"test command",
]
"#);
assert!(approvals.is_command_approved("github.com/test/repo", "test command"));
}
#[test]
fn test_duplicate_approvals_not_saved_twice() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/test/repo".to_string(),
"test".to_string(),
Some(&approvals_path),
)
.ok();
approvals
.approve_command(
"github.com/test/repo".to_string(),
"test".to_string(),
Some(&approvals_path),
)
.ok();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert_snapshot!(toml_content, @r#"
[projects."github.com/test/repo"]
approved-commands = [
"test",
]
"#);
}
#[test]
fn test_multiple_project_approvals() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/user1/repo1".to_string(),
"npm install".to_string(),
Some(&approvals_path),
)
.unwrap();
approvals
.approve_command(
"github.com/user2/repo2".to_string(),
"cargo build".to_string(),
Some(&approvals_path),
)
.unwrap();
approvals
.approve_command(
"github.com/user1/repo1".to_string(),
"npm test".to_string(),
Some(&approvals_path),
)
.unwrap();
assert!(approvals.is_command_approved("github.com/user1/repo1", "npm install"));
assert!(approvals.is_command_approved("github.com/user2/repo2", "cargo build"));
assert!(approvals.is_command_approved("github.com/user1/repo1", "npm test"));
assert!(!approvals.is_command_approved("github.com/user1/repo1", "cargo build"));
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert_snapshot!(toml_content, @r#"
[projects."github.com/user1/repo1"]
approved-commands = [
"npm install",
"npm test",
]
[projects."github.com/user2/repo2"]
approved-commands = [
"cargo build",
]
"#);
}
#[test]
fn test_isolated_config_safety() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("isolated.toml");
use etcetera::base_strategy::{BaseStrategy, choose_base_strategy};
let user_approvals_path = if let Ok(strategy) = choose_base_strategy() {
strategy
.config_dir()
.join("worktrunk")
.join("approvals.toml")
} else {
std::env::var("HOME")
.map(|home| std::path::PathBuf::from(home).join(".config/worktrunk/approvals.toml"))
.unwrap_or_else(|_| temp_dir.path().join("dummy.toml"))
};
let user_approvals_before = if user_approvals_path.exists() {
Some(fs::read_to_string(&user_approvals_path).unwrap())
} else {
None
};
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/safety-test/repo".to_string(),
"THIS SHOULD NOT APPEAR IN USER APPROVALS".to_string(),
Some(&approvals_path),
)
.unwrap();
let user_approvals_after = if user_approvals_path.exists() {
Some(fs::read_to_string(&user_approvals_path).unwrap())
} else {
None
};
assert_eq!(
user_approvals_before, user_approvals_after,
"User approvals file was modified by isolated test!"
);
let isolated_content = fs::read_to_string(&approvals_path).unwrap();
assert!(isolated_content.contains("THIS SHOULD NOT APPEAR IN USER APPROVALS"));
}
#[test]
fn test_yes_flag_does_not_save_approval() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let initial_config = UserConfig::default();
initial_config.save_to(&config_path).unwrap();
let saved_config = fs::read_to_string(&config_path).unwrap();
assert_snapshot!(saved_config, @"");
}
#[test]
fn test_approval_saves_to_new_approvals_file() {
let temp_dir = TempDir::new().unwrap();
let approvals_dir = temp_dir.path().join("nested").join("config");
let approvals_path = approvals_dir.join("approvals.toml");
assert!(!approvals_path.exists());
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/test/nested".to_string(),
"test command".to_string(),
Some(&approvals_path),
)
.unwrap();
assert!(approvals_path.exists());
assert!(approvals_dir.exists());
let content = fs::read_to_string(&approvals_path).unwrap();
assert_snapshot!(content, @r#"
[projects."github.com/test/nested"]
approved-commands = [
"test command",
]
"#);
}
#[test]
fn test_saving_config_mutation_preserves_toml_comments() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let initial_content = r#"# User preferences for worktrunk
# These comments should be preserved after saving
worktree-path = "../{{ main_worktree }}.{{ branch }}" # inline comment should also be preserved
# LLM commit generation settings
[commit.generation]
command = "llm -m claude-haiku-4.5"
# Per-project settings below
"#;
fs::write(&config_path, initial_content).unwrap();
let toml_str = fs::read_to_string(&config_path).unwrap();
let mut config: UserConfig = toml::from_str(&toml_str).unwrap();
config
.set_commit_generation_command("llm -m claude-sonnet-4".to_string(), Some(&config_path))
.unwrap();
let saved_content = fs::read_to_string(&config_path).unwrap();
assert!(
saved_content.contains("# User preferences for worktrunk"),
"Top-level comment was lost. Saved content:\n{saved_content}"
);
assert!(
saved_content.contains("# LLM commit generation settings"),
"Section comment was lost. Saved content:\n{saved_content}"
);
assert!(
saved_content.contains("# inline comment should also be preserved"),
"Inline comment was lost. Saved content:\n{saved_content}"
);
assert!(
saved_content.contains("llm -m claude-sonnet-4"),
"Command was not updated. Saved content:\n{saved_content}"
);
}
#[test]
fn test_concurrent_approve_preserves_all_approvals() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut approvals_a = Approvals::default();
let mut approvals_b = Approvals::default();
approvals_a
.approve_command(
"github.com/user/repo".to_string(),
"npm install".to_string(),
Some(&approvals_path),
)
.unwrap();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert!(
toml_content.contains("npm install"),
"File should contain 'npm install'"
);
approvals_b
.approve_command(
"github.com/user/repo".to_string(),
"npm test".to_string(),
Some(&approvals_path),
)
.unwrap();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert!(
toml_content.contains("npm install"),
"BUG: 'npm install' approval was lost due to race condition. \
approvals_b's save should merge with disk state, not overwrite it. \
Saved content:\n{toml_content}"
);
assert!(
toml_content.contains("npm test"),
"'npm test' approval should exist. Saved content:\n{toml_content}"
);
}
#[test]
fn test_concurrent_revoke_preserves_all_changes() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut setup_approvals = Approvals::default();
setup_approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm install".to_string(),
Some(&approvals_path),
)
.unwrap();
setup_approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm test".to_string(),
Some(&approvals_path),
)
.unwrap();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert!(toml_content.contains("npm install"));
assert!(toml_content.contains("npm test"));
let mut approvals_a = Approvals::default();
approvals_a
.revoke_project("github.com/user/repo", Some(&approvals_path))
.unwrap();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert!(
!toml_content.contains("npm install"),
"'npm install' should have been revoked. Saved content:\n{toml_content}"
);
assert!(
!toml_content.contains("npm test"),
"'npm test' should have been revoked. Saved content:\n{toml_content}"
);
}
#[test]
fn test_concurrent_approve_different_projects() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let mut approvals_a = Approvals::default();
let mut approvals_b = Approvals::default();
approvals_a
.approve_command(
"github.com/user/project1".to_string(),
"npm install".to_string(),
Some(&approvals_path),
)
.unwrap();
approvals_b
.approve_command(
"github.com/user/project2".to_string(),
"cargo build".to_string(),
Some(&approvals_path),
)
.unwrap();
let toml_content = fs::read_to_string(&approvals_path).unwrap();
assert!(
toml_content.contains("github.com/user/project1"),
"Project1 should be preserved. Content:\n{toml_content}"
);
assert!(
toml_content.contains("npm install"),
"'npm install' should be preserved. Content:\n{toml_content}"
);
assert!(
toml_content.contains("github.com/user/project2"),
"Project2 should exist. Content:\n{toml_content}"
);
assert!(
toml_content.contains("cargo build"),
"'cargo build' should exist. Content:\n{toml_content}"
);
}
#[test]
fn test_truly_concurrent_approve_with_threads() {
use std::sync::{Arc, Barrier};
use std::thread;
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let num_threads = 10;
let barrier = Arc::new(Barrier::new(num_threads));
let approvals_path = Arc::new(approvals_path);
let handles: Vec<_> = (0..num_threads)
.map(|i| {
let barrier = Arc::clone(&barrier);
let approvals_path = Arc::clone(&approvals_path);
thread::spawn(move || {
let mut approvals = Approvals::default();
barrier.wait();
approvals
.approve_command(
"github.com/user/repo".to_string(),
format!("command_{i}"),
Some(&approvals_path),
)
.unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let toml_content = fs::read_to_string(&*approvals_path).unwrap();
for i in 0..num_threads {
assert!(
toml_content.contains(&format!("command_{i}")),
"command_{i} should be preserved. With file locking, no approvals should be lost.\n\
Content:\n{toml_content}"
);
}
}
#[test]
#[cfg(unix)]
fn test_permission_error_prevents_save() {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("readonly").join("approvals.toml");
let approvals_dir = approvals_path.parent().unwrap();
fs::create_dir_all(approvals_dir).unwrap();
let initial_approvals = Approvals::default();
initial_approvals.save_to(&approvals_path).unwrap();
#[cfg(unix)]
{
let readonly_perms = Permissions::from_mode(0o444);
fs::set_permissions(approvals_dir, readonly_perms).unwrap();
}
let test_file = approvals_dir.join("test_write");
if fs::write(&test_file, "test").is_ok() {
#[cfg(unix)]
{
let writable_perms = Permissions::from_mode(0o755);
fs::set_permissions(approvals_dir, writable_perms).unwrap();
}
eprintln!("Skipping permission test - running with elevated privileges");
return;
}
let mut approvals = Approvals::default();
let result = approvals.approve_command(
"github.com/test/readonly".to_string(),
"test command".to_string(),
Some(&approvals_path),
);
#[cfg(unix)]
{
let writable_perms = Permissions::from_mode(0o755);
fs::set_permissions(approvals_dir, writable_perms).unwrap();
}
assert!(
result.is_err(),
"Expected save to fail due to permissions, but it succeeded"
);
}
#[test]
fn test_skip_shell_integration_prompt_saves_to_disk() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("worktrunk").join("config.toml");
let mut config = UserConfig::default();
config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap();
assert!(
config_path.exists(),
"Config file was not created at {:?}",
config_path
);
let toml_content = fs::read_to_string(&config_path).unwrap();
assert_snapshot!(toml_content, @"skip-shell-integration-prompt = true");
}
#[test]
fn test_skip_shell_integration_prompt_idempotent() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut config = UserConfig::default();
config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap();
config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap();
assert!(config.skip_shell_integration_prompt);
let toml_content = fs::read_to_string(&config_path).unwrap();
let count = toml_content
.matches("skip-shell-integration-prompt")
.count();
assert_eq!(count, 1, "Flag should appear exactly once");
}
#[test]
fn test_skip_commit_generation_prompt_saves_to_disk() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("worktrunk").join("config.toml");
let mut config = UserConfig::default();
config
.set_skip_commit_generation_prompt(Some(&config_path))
.unwrap();
assert!(
config_path.exists(),
"Config file was not created at {:?}",
config_path
);
let toml_content = fs::read_to_string(&config_path).unwrap();
assert_snapshot!(toml_content, @"skip-commit-generation-prompt = true");
}
#[test]
fn test_skip_commit_generation_prompt_idempotent() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut config = UserConfig::default();
config
.set_skip_commit_generation_prompt(Some(&config_path))
.unwrap();
config
.set_skip_commit_generation_prompt(Some(&config_path))
.unwrap();
assert!(config.skip_commit_generation_prompt);
let toml_content = fs::read_to_string(&config_path).unwrap();
let count = toml_content
.matches("skip-commit-generation-prompt")
.count();
assert_eq!(count, 1, "Flag should appear exactly once");
}
#[test]
fn test_set_commit_generation_command_saves_to_disk() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("worktrunk").join("config.toml");
let mut config = UserConfig::default();
config
.set_commit_generation_command("llm -m haiku".to_string(), Some(&config_path))
.unwrap();
assert!(
config_path.exists(),
"Config file was not created at {:?}",
config_path
);
let toml_content = fs::read_to_string(&config_path).unwrap();
assert_snapshot!(toml_content, @r#"
[commit.generation]
command = "llm -m haiku"
"#);
}
#[test]
fn test_set_commit_generation_command_with_special_chars() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut config = UserConfig::default();
let command =
"MAX_THINKING_TOKENS=0 claude -p --model=haiku --tools='' --system-prompt=''".to_string();
config
.set_commit_generation_command(command, Some(&config_path))
.unwrap();
let toml_content = fs::read_to_string(&config_path).unwrap();
let parsed: UserConfig = toml::from_str(&toml_content).unwrap();
let gen_config = parsed.commit_generation(None);
assert_eq!(
gen_config.command.as_deref(),
Some("MAX_THINKING_TOKENS=0 claude -p --model=haiku --tools='' --system-prompt=''")
);
}
#[test]
#[cfg(unix)]
fn test_saving_through_symlink_preserves_symlink() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let dotfiles_dir = temp_dir.path().join("dotfiles");
fs::create_dir_all(&dotfiles_dir).unwrap();
let target_path = dotfiles_dir.join("worktrunk.toml");
let initial_content = r#"# My dotfiles config
worktree-path = "../{{ main_worktree }}.{{ branch }}"
"#;
fs::write(&target_path, initial_content).unwrap();
let config_dir = temp_dir.path().join("config").join("worktrunk");
fs::create_dir_all(&config_dir).unwrap();
let symlink_path = config_dir.join("config.toml");
symlink(&target_path, &symlink_path).unwrap();
assert!(symlink_path.is_symlink(), "Should be a symlink before save");
assert_eq!(
fs::read_link(&symlink_path).unwrap(),
target_path,
"Symlink should point to target"
);
let toml_str = fs::read_to_string(&symlink_path).unwrap();
let mut config: UserConfig = toml::from_str(&toml_str).unwrap();
config
.set_commit_generation_command("llm -m haiku".to_string(), Some(&symlink_path))
.unwrap();
assert!(
symlink_path.is_symlink(),
"Symlink should be preserved after save"
);
assert_eq!(
fs::read_link(&symlink_path).unwrap(),
target_path,
"Symlink target should be unchanged"
);
let target_content = fs::read_to_string(&target_path).unwrap();
assert!(
target_content.contains("llm -m haiku"),
"Command should be written to target. Content:\n{target_content}"
);
assert!(
target_content.contains("# My dotfiles config"),
"Comments should be preserved. Content:\n{target_content}"
);
let symlink_content = fs::read_to_string(&symlink_path).unwrap();
assert_eq!(
target_content, symlink_content,
"Content should be identical whether read through symlink or target"
);
}
#[test]
fn test_set_commit_generation_command_preserves_existing_content() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let initial_content = r#"# My settings
worktree-path = "../{{ repo }}.{{ branch }}"
[projects."github.com/user/repo"]
approved-commands = [
"npm install",
]
"#;
fs::write(&config_path, initial_content).unwrap();
let toml_str = fs::read_to_string(&config_path).unwrap();
let mut config: UserConfig = toml::from_str(&toml_str).unwrap();
config
.set_commit_generation_command("llm -m haiku".to_string(), Some(&config_path))
.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("worktree-path = \"../{{ repo }}.{{ branch }}\""),
"worktree-path should be preserved. Saved content:\n{saved}"
);
assert!(
saved.contains("npm install"),
"approved-commands should be preserved. Saved content:\n{saved}"
);
assert!(
saved.contains("# My settings"),
"Comments should be preserved. Saved content:\n{saved}"
);
assert!(
saved.contains("[commit.generation]"),
"[commit.generation] section should be added. Saved content:\n{saved}"
);
assert!(
saved.contains("llm -m haiku"),
"command should be saved. Saved content:\n{saved}"
);
assert!(
!saved.contains("[commit]\n"),
"Should not have standalone [commit] header when only generation is set:\n{saved}"
);
}