pub mod mock_commands;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::sanitize_branch_name;
use crate::git::Repository;
use crate::shell_exec::Cmd;
use self::mock_commands::{MockConfig, MockResponse};
pub fn wt_bin() -> PathBuf {
if let Some(path) = option_env!("CARGO_BIN_EXE_wt") {
return PathBuf::from(path);
}
PathBuf::from(
std::env::var("CARGO_BIN_EXE_wt")
.expect("CARGO_BIN_EXE_wt not set — only available during `cargo test`"),
)
}
pub fn workspace_bin(name: &str) -> PathBuf {
let mut path = std::env::current_exe().expect("failed to get test executable path");
path.pop(); path.pop();
#[cfg(windows)]
path.push(format!("{name}.exe"));
#[cfg(not(windows))]
path.push(name);
path
}
use tempfile::TempDir;
fn standard_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/standard")
}
struct FixtureWorktrees {
worktrees: HashMap<String, PathBuf>,
remote: PathBuf,
}
fn copy_standard_fixture(dest: &Path) -> FixtureWorktrees {
fn copy_dir_recursive(src: &Path, dest: &Path) {
std::fs::create_dir_all(dest).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let file_type = entry.file_type().unwrap();
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path);
} else if file_type.is_file() {
std::fs::copy(&src_path, &dest_path).unwrap();
}
}
}
let fixture = standard_fixture_path();
copy_dir_recursive(&fixture, dest);
let essential = ["repo/_git", "origin_git", "repo.feature-a/_git"];
for path in essential {
let full_path = dest.join(path);
assert!(
full_path.exists(),
"Essential fixture path missing after copy: {:?}",
full_path
);
}
let renames = [
("repo/_git", "repo/.git"),
("origin_git", "origin.git"),
("repo.feature-a/_git", "repo.feature-a/.git"),
("repo.feature-b/_git", "repo.feature-b/.git"),
("repo.feature-c/_git", "repo.feature-c/.git"),
];
for (from, to) in renames {
let from_path = dest.join(from);
let to_path = dest.join(to);
if from_path.exists() {
std::fs::rename(&from_path, &to_path).unwrap_or_else(|e| {
panic!("Failed to rename {:?} to {:?}: {}", from_path, to_path, e)
});
}
}
let origin_git = dest.join("origin.git");
assert!(
origin_git.join("HEAD").exists(),
"origin.git is not a valid git repository (missing HEAD): {:?}",
origin_git
);
let canonical_dest = canonicalize(dest).unwrap();
for wt in ["feature-a", "feature-b", "feature-c"] {
let gitdir_path = dest.join(format!("repo.{wt}/.git"));
if gitdir_path.exists() {
let content = std::fs::read_to_string(&gitdir_path).unwrap();
let fixed = content.replace("_git", ".git");
std::fs::write(&gitdir_path, fixed).unwrap();
}
let main_gitdir = dest.join(format!("repo/.git/worktrees/repo.{wt}/gitdir"));
if main_gitdir.exists() {
let content = std::fs::read_to_string(&main_gitdir).unwrap();
let fixed = content.replace("_git", ".git");
std::fs::write(&main_gitdir, fixed).unwrap();
}
}
let config_path = dest.join("repo/.git/config");
if config_path.exists() {
let content = std::fs::read_to_string(&config_path).unwrap();
let fixed = content.replace("origin_git", "origin.git");
std::fs::write(&config_path, fixed).unwrap();
}
let mut worktrees = HashMap::new();
for wt in ["feature-a", "feature-b", "feature-c"] {
worktrees.insert(wt.to_string(), canonical_dest.join(format!("repo.{wt}")));
}
let remote = canonical_dest.join("origin.git");
FixtureWorktrees { worktrees, remote }
}
fn write_test_gitconfig(path: &Path) {
std::fs::write(
path,
"[user]\n\tname = Test User\n\temail = test@example.com\n\
[advice]\n\tmergeConflict = false\n\tresolveConflict = false\n\
[init]\n\tdefaultBranch = main\n\
[commit]\n\tgpgsign = false\n\
[rerere]\n\tenabled = true\n",
)
.unwrap();
}
pub fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
pub const MINUTE: i64 = 60;
pub const HOUR: i64 = 60 * MINUTE;
pub const DAY: i64 = 24 * HOUR;
pub const WEEK: i64 = 7 * DAY;
pub const TEST_EPOCH: u64 = 1735776000;
const BG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub const STATIC_TEST_ENV_VARS: &[(&str, &str)] = &[
("CLICOLOR_FORCE", "1"),
("COLUMNS", "150"),
("LC_ALL", "C"),
("LANG", "C"),
("WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK", "1"),
("WORKTRUNK_TEST_DELAYED_STREAM_MS", "-1"),
];
#[cfg(windows)]
pub const NULL_DEVICE: &str = "NUL";
#[cfg(not(windows))]
pub const NULL_DEVICE: &str = "/dev/null";
#[must_use]
pub fn wt_command() -> Command {
let mut cmd = Command::new(wt_bin());
configure_cli_command(&mut cmd);
cmd
}
pub fn wt_completion_command(words: &[&str]) -> Command {
assert!(
matches!(words.first(), Some(&"wt")),
"completion words must include command name as the first element"
);
let mut cmd = wt_command();
configure_completion_invocation(&mut cmd, words);
cmd
}
pub fn configure_completion_invocation(cmd: &mut Command, words: &[&str]) {
configure_completion_invocation_for_shell(cmd, words, "bash");
}
pub fn configure_completion_invocation_for_shell(cmd: &mut Command, words: &[&str], shell: &str) {
cmd.arg("--");
cmd.args(words);
cmd.env("COMPLETE", shell);
cmd.env("_CLAP_IFS", "\n");
match shell {
"bash" | "zsh" => {
let index = words.len().saturating_sub(1);
cmd.env("_CLAP_COMPLETE_INDEX", index.to_string());
}
"fish" | "nu" => {
}
_ => {}
}
}
pub fn configure_cli_command(cmd: &mut Command) {
for (key, _) in std::env::vars() {
if key.starts_with("GIT_") || key.starts_with("WORKTRUNK_") {
cmd.env_remove(&key);
}
}
cmd.env_remove("NO_COLOR");
cmd.env("WORKTRUNK_CONFIG_PATH", "/nonexistent/test/config.toml");
cmd.env(
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"/etc/xdg/worktrunk/config.toml",
);
cmd.env(
"WORKTRUNK_APPROVALS_PATH",
"/nonexistent/test/approvals.toml",
);
cmd.env_remove("SHELL");
cmd.env_remove("PSModulePath");
cmd.env("WORKTRUNK_TEST_POWERSHELL_ENV", "0");
cmd.env("WORKTRUNK_TEST_NUSHELL_ENV", "0");
cmd.env("WORKTRUNK_TEST_EPOCH", TEST_EPOCH.to_string());
cmd.env("RUST_LOG", "warn");
cmd.env("WORKTRUNK_TEST_CLAUDE_INSTALLED", "0");
cmd.env("WORKTRUNK_TEST_OPENCODE_INSTALLED", "0");
for &(key, value) in STATIC_TEST_ENV_VARS {
cmd.env(key, value);
}
cmd.env("COLUMNS", "500");
cmd.env("TERM", "alacritty");
for key in [
"LLVM_PROFILE_FILE",
"CARGO_LLVM_COV",
"CARGO_LLVM_COV_TARGET_DIR",
] {
if let Ok(val) = std::env::var(key) {
cmd.env(key, val);
}
}
}
pub fn configure_git_cmd(cmd: &mut Command, git_config_path: &Path) {
cmd.env("GIT_CONFIG_GLOBAL", git_config_path);
cmd.env("GIT_CONFIG_SYSTEM", NULL_DEVICE);
cmd.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z");
cmd.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z");
cmd.env("LC_ALL", "C");
cmd.env("LANG", "C");
cmd.env("WORKTRUNK_TEST_EPOCH", TEST_EPOCH.to_string());
cmd.env("GIT_TERMINAL_PROMPT", "0");
}
pub fn configure_git_env(cmd: Cmd, git_config_path: &Path) -> Cmd {
cmd.env("GIT_CONFIG_GLOBAL", git_config_path)
.env("GIT_CONFIG_SYSTEM", NULL_DEVICE)
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.env("LC_ALL", "C")
.env("LANG", "C")
.env("WORKTRUNK_TEST_EPOCH", TEST_EPOCH.to_string())
.env("GIT_TERMINAL_PROMPT", "0")
}
pub trait TestRepoBase {
fn git_config_path(&self) -> &Path;
fn configure_git_cmd(&self, cmd: &mut Command) {
configure_git_cmd(cmd, self.git_config_path());
}
fn git_command(&self, dir: &Path) -> Cmd {
configure_git_env(Cmd::new("git"), self.git_config_path()).current_dir(dir)
}
fn run_git_in(&self, dir: &Path, args: &[&str]) {
let output = self
.git_command(dir)
.args(args.iter().copied())
.run()
.unwrap();
check_git_status(&output, &args.join(" "));
}
fn commit_in(&self, dir: &Path, message: &str) {
std::fs::write(dir.join("file.txt"), message).unwrap();
self.run_git_in(dir, &["add", "file.txt"]);
let output = self
.git_command(dir)
.args(["commit", "-m", message])
.run()
.unwrap();
if !output.status.success() {
panic!(
"Failed to commit:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
}
pub fn directive_files() -> (PathBuf, PathBuf, (tempfile::TempPath, tempfile::TempPath)) {
let cd = tempfile::NamedTempFile::new().expect("failed to create cd temp file");
let exec = tempfile::NamedTempFile::new().expect("failed to create exec temp file");
let cd_path = cd.path().to_path_buf();
let exec_path = exec.path().to_path_buf();
(
cd_path,
exec_path,
(cd.into_temp_path(), exec.into_temp_path()),
)
}
pub fn configure_directive_files(cmd: &mut Command, cd_path: &Path, exec_path: &Path) {
cmd.env("WORKTRUNK_DIRECTIVE_CD_FILE", cd_path);
cmd.env("WORKTRUNK_DIRECTIVE_EXEC_FILE", exec_path);
}
pub fn configure_directive_cd_only(cmd: &mut Command, cd_path: &Path) {
cmd.env("WORKTRUNK_DIRECTIVE_CD_FILE", cd_path);
}
pub fn legacy_directive_file() -> (PathBuf, tempfile::TempPath) {
let file = tempfile::NamedTempFile::new().expect("failed to create temp file");
let path = file.path().to_path_buf();
(path, file.into_temp_path())
}
pub fn configure_legacy_directive_file(cmd: &mut Command, path: &Path) {
cmd.env("WORKTRUNK_DIRECTIVE_FILE", path);
}
pub fn set_temp_home_env(cmd: &mut Command, home: &Path) {
let home = canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
cmd.env("HOME", &home);
cmd.env("XDG_CONFIG_HOME", home.join(".config"));
cmd.env("USERPROFILE", &home);
cmd.env("APPDATA", home.join(".config"));
cmd.env("OPENCODE_CONFIG_DIR", home.join("opencode-config"));
}
pub fn set_xdg_config_path(cmd: &mut Command, home: &Path) {
let home = canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
cmd.env(
"WORKTRUNK_CONFIG_PATH",
home.join(".config").join("worktrunk").join("config.toml"),
);
}
pub fn check_git_status(output: &std::process::Output, cmd_desc: &str) {
if !output.status.success() {
panic!(
"git {} failed:\nstdout: {}\nstderr: {}",
cmd_desc,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
pub struct TestRepo {
temp_dir: TempDir, root: PathBuf,
pub repo: Repository,
pub worktrees: HashMap<String, PathBuf>,
remote: Option<PathBuf>, test_config_path: PathBuf,
test_approvals_path: PathBuf,
git_config_path: PathBuf,
mock_bin_path: Option<PathBuf>,
claude_installed: bool,
opencode_installed: bool,
}
impl TestRepo {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
let repo = Self::init_repo(&["init", "-b", "main"]);
repo.repo
.run_command(&["config", "user.name", "Test User"])
.unwrap();
repo.repo
.run_command(&["config", "user.email", "test@example.com"])
.unwrap();
repo
}
pub fn with_initial_commit() -> Self {
let test = Self::new();
std::fs::write(test.path().join("file.txt"), "hello").unwrap();
test.run_git(&["add", "."]);
test.run_git(&["commit", "-m", "init"]);
test
}
pub fn bare() -> Self {
Self::init_repo(&["init", "--bare"])
}
pub fn path(&self) -> &Path {
self.root_path()
}
pub fn standard() -> Self {
let temp_dir = TempDir::new().unwrap();
let fixture = copy_standard_fixture(temp_dir.path());
let root = canonicalize(&temp_dir.path().join("repo")).unwrap();
let test_config_path = temp_dir.path().join("test-config.toml");
let test_approvals_path = temp_dir.path().join("test-approvals.toml");
let git_config_path = temp_dir.path().join("test-gitconfig");
write_test_gitconfig(&git_config_path);
let mut repo = Self {
temp_dir,
root: root.clone(),
repo: Repository::at(&root).unwrap(),
worktrees: fixture.worktrees,
remote: Some(fixture.remote),
test_config_path,
test_approvals_path,
git_config_path,
mock_bin_path: None,
claude_installed: false,
opencode_installed: false,
};
repo.setup_mock_gh();
repo
}
pub fn at(path: &Path) -> Self {
std::fs::create_dir_all(path).unwrap();
let config_dir = TempDir::new().unwrap();
let test_config_path = config_dir.path().join("test-config.toml");
let test_approvals_path = config_dir.path().join("test-approvals.toml");
let git_config_path = config_dir.path().join("test-gitconfig");
write_test_gitconfig(&git_config_path);
configure_git_env(Cmd::new("git"), &git_config_path)
.args(["init", "-b", "main", "--quiet"])
.current_dir(path)
.run()
.unwrap();
let root = canonicalize(path).unwrap();
let repo = Repository::at(&root).unwrap();
repo.run_command(&["config", "user.name", "Test User"])
.unwrap();
repo.run_command(&["config", "user.email", "test@example.com"])
.unwrap();
Self {
temp_dir: config_dir,
root: root.clone(),
repo,
worktrees: HashMap::new(),
remote: None,
test_config_path,
test_approvals_path,
git_config_path,
mock_bin_path: None,
claude_installed: false,
opencode_installed: false,
}
}
pub fn empty() -> Self {
Self::init_repo(&["init", "-q"])
}
fn init_repo(git_args: &[&str]) -> Self {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path().join("repo");
std::fs::create_dir(&root).unwrap();
let test_config_path = temp_dir.path().join("test-config.toml");
let test_approvals_path = temp_dir.path().join("test-approvals.toml");
let git_config_path = temp_dir.path().join("test-gitconfig");
write_test_gitconfig(&git_config_path);
configure_git_env(Cmd::new("git"), &git_config_path)
.args(git_args.iter().copied())
.current_dir(&root)
.run()
.unwrap();
let root = canonicalize(&root).unwrap();
Self {
temp_dir,
root: root.clone(),
repo: Repository::at(&root).unwrap(),
worktrees: HashMap::new(),
remote: None,
test_config_path,
test_approvals_path,
git_config_path,
mock_bin_path: None,
claude_installed: false,
opencode_installed: false,
}
}
pub fn configure_git_cmd(&self, cmd: &mut Command) {
configure_git_cmd(cmd, &self.git_config_path);
}
#[cfg_attr(windows, allow(dead_code))] pub fn test_env_vars(&self) -> Vec<(String, String)> {
let mut vars: Vec<(String, String)> = STATIC_TEST_ENV_VARS
.iter()
.map(|&(k, v)| (k.to_string(), v.to_string()))
.collect();
vars.extend([
(
"GIT_CONFIG_GLOBAL".to_string(),
self.git_config_path.display().to_string(),
),
("GIT_CONFIG_SYSTEM".to_string(), NULL_DEVICE.to_string()),
(
"GIT_AUTHOR_DATE".to_string(),
"2025-01-01T00:00:00Z".to_string(),
),
(
"GIT_COMMITTER_DATE".to_string(),
"2025-01-01T00:00:00Z".to_string(),
),
("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
("HOME".to_string(), self.home_path().display().to_string()),
(
"XDG_CONFIG_HOME".to_string(),
self.home_path().join(".config").display().to_string(),
),
("WORKTRUNK_TEST_EPOCH".to_string(), TEST_EPOCH.to_string()),
(
"WORKTRUNK_CONFIG_PATH".to_string(),
self.test_config_path().display().to_string(),
),
(
"WORKTRUNK_SYSTEM_CONFIG_PATH".to_string(),
"/etc/xdg/worktrunk/config.toml".to_string(),
),
(
"WORKTRUNK_APPROVALS_PATH".to_string(),
self.test_approvals_path().display().to_string(),
),
]);
vars
}
#[cfg_attr(windows, allow(dead_code))] pub fn configure_shell_integration(&self) {
let zshrc_path = self.home_path().join(".zshrc");
std::fs::write(
&zshrc_path,
"if command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.expect("Failed to write .zshrc for test");
}
#[must_use]
pub fn git_command(&self) -> Cmd {
configure_git_env(Cmd::new("git"), &self.git_config_path).current_dir(&self.root)
}
pub fn run_git(&self, args: &[&str]) {
let output = self.git_command().args(args.iter().copied()).run().unwrap();
check_git_status(&output, &args.join(" "));
}
pub fn run_git_in(&self, dir: &Path, args: &[&str]) {
let output = self
.git_command()
.args(args.iter().copied())
.current_dir(dir)
.run()
.unwrap();
check_git_status(&output, &args.join(" "));
}
pub fn git_output(&self, args: &[&str]) -> String {
let output = self.git_command().args(args.iter().copied()).run().unwrap();
check_git_status(&output, &args.join(" "));
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
pub fn remove_fixture_worktrees(&mut self) {
for branch in &["feature-a", "feature-b", "feature-c"] {
let worktree_path = self
.root_path()
.parent()
.unwrap()
.join(format!("repo.{}", branch));
if worktree_path.exists() {
let _ = self
.git_command()
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.run();
}
let _ = self.git_command().args(["branch", "-D", branch]).run();
self.worktrees.remove(*branch);
}
}
pub fn stage_all(&self, dir: &Path) {
self.run_git_in(dir, &["add", "."]);
}
pub fn head_sha(&self) -> String {
let output = self
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
check_git_status(&output, "rev-parse HEAD");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
pub fn head_sha_in(&self, dir: &Path) -> String {
let output = self
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.run()
.unwrap();
check_git_status(&output, "rev-parse HEAD");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
pub fn configure_wt_cmd(&self, cmd: &mut Command) {
configure_cli_command(cmd);
self.configure_git_cmd(cmd);
cmd.env("WORKTRUNK_CONFIG_PATH", &self.test_config_path);
cmd.env(
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"/etc/xdg/worktrunk/config.toml",
);
cmd.env("WORKTRUNK_APPROVALS_PATH", &self.test_approvals_path);
set_temp_home_env(cmd, self.home_path());
self.configure_mock_commands(cmd);
}
#[must_use]
pub fn wt_command(&self) -> Command {
let mut cmd = Command::new(wt_bin());
self.configure_wt_cmd(&mut cmd);
cmd.current_dir(self.root_path());
cmd
}
pub fn home_path(&self) -> &Path {
self.temp_dir.path()
}
pub fn completion_cmd(&self, words: &[&str]) -> Command {
self.completion_cmd_for_shell(words, "bash")
}
pub fn completion_cmd_for_shell(&self, words: &[&str], shell: &str) -> Command {
let mut cmd = wt_command();
configure_completion_invocation_for_shell(&mut cmd, words, shell);
self.configure_wt_cmd(&mut cmd);
cmd.current_dir(self.root_path());
cmd
}
pub fn root_path(&self) -> &Path {
&self.root
}
pub fn mock_bin_path(&self) -> Option<&Path> {
self.mock_bin_path.as_deref()
}
pub fn remote_path(&self) -> Option<&Path> {
self.remote.as_deref()
}
pub fn project_id(&self) -> String {
dunce::canonicalize(&self.root)
.unwrap_or_else(|_| self.root.clone())
.to_str()
.unwrap_or("")
.to_string()
}
pub fn test_config_path(&self) -> &Path {
&self.test_config_path
}
pub fn test_approvals_path(&self) -> &Path {
&self.test_approvals_path
}
pub fn write_project_config(&self, contents: &str) {
let config_dir = self.root_path().join(".config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("wt.toml"), contents).unwrap();
}
pub fn write_test_config(&self, contents: &str) {
let full_contents = format!("skip-commit-generation-prompt = true\n{}", contents);
std::fs::write(&self.test_config_path, full_contents).unwrap();
}
pub fn write_test_approvals(&self, contents: &str) {
std::fs::write(&self.test_approvals_path, contents).unwrap();
}
pub fn worktree_path(&self, name: &str) -> &Path {
self.worktrees
.get(name)
.unwrap_or_else(|| panic!("Worktree '{}' not found", name))
}
pub fn commit(&self, message: &str) {
let file_path = self.root.join("file.txt");
std::fs::write(&file_path, message).unwrap();
self.git_command().args(["add", "."]).run().unwrap();
self.git_command()
.args(["commit", "-m", message])
.run()
.unwrap();
}
pub fn commit_with_message(&self, message: &str) {
let sanitized: String = message
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
.take(16)
.collect();
let file_path = self.root.join(format!("file-{}.txt", sanitized));
std::fs::write(&file_path, message).unwrap();
self.git_command().args(["add", "."]).run().unwrap();
self.git_command()
.args(["commit", "-m", message])
.run()
.unwrap();
}
pub fn commit_with_age(&self, message: &str, age_seconds: i64) {
let commit_time = TEST_EPOCH as i64 - age_seconds;
let timestamp = unix_to_iso8601(commit_time);
let file_path = self.root.join("file.txt");
std::fs::write(&file_path, message).unwrap();
self.git_command().args(["add", "."]).run().unwrap();
self.git_command()
.env("GIT_AUTHOR_DATE", ×tamp)
.env("GIT_COMMITTER_DATE", ×tamp)
.args(["commit", "-m", message])
.run()
.unwrap();
}
pub fn commit_staged_with_age(&self, message: &str, age_seconds: i64, dir: &Path) {
let commit_time = TEST_EPOCH as i64 - age_seconds;
let timestamp = unix_to_iso8601(commit_time);
self.git_command()
.env("GIT_AUTHOR_DATE", ×tamp)
.env("GIT_COMMITTER_DATE", ×tamp)
.args(["commit", "-m", message])
.current_dir(dir)
.run()
.unwrap();
}
pub fn add_worktree(&mut self, branch: &str) -> PathBuf {
if let Some(path) = self.worktrees.get(branch) {
return path.clone();
}
let safe_branch = sanitize_branch_name(branch);
let worktree_path = self.temp_dir.path().join(format!("repo.{}", safe_branch));
let worktree_str = worktree_path.to_str().unwrap();
self.run_git(&["worktree", "add", "-b", branch, worktree_str]);
let canonical_path = canonicalize(&worktree_path).unwrap();
self.worktrees
.insert(branch.to_string(), canonical_path.clone());
canonical_path
}
pub fn add_worktree_at_path(&mut self, branch: &str, path: &Path) -> PathBuf {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let path_str = path.to_str().unwrap();
self.run_git(&["worktree", "add", "-b", branch, path_str]);
let canonical_path = canonicalize(path).unwrap();
self.worktrees
.insert(branch.to_string(), canonical_path.clone());
canonical_path
}
pub fn add_main_worktree(&self) -> PathBuf {
if self.current_branch() == "main" {
self.detach_head();
}
let main_wt = self.root_path().parent().unwrap().join("repo.main-wt");
let main_wt_str = main_wt.to_str().unwrap();
self.run_git(&["worktree", "add", main_wt_str, "main"]);
main_wt
}
pub fn add_worktree_with_commit(
&mut self,
branch: &str,
filename: &str,
content: &str,
message: &str,
) -> PathBuf {
let worktree_path = self.add_worktree(branch);
std::fs::write(worktree_path.join(filename), content).unwrap();
self.run_git_in(&worktree_path, &["add", filename]);
self.run_git_in(&worktree_path, &["commit", "-m", message]);
worktree_path
}
pub fn add_feature(&mut self) -> PathBuf {
self.add_worktree_with_commit(
"feature",
"feature.txt",
"feature content",
"Add feature file",
)
}
pub fn commit_in_worktree(
&self,
worktree_path: &Path,
filename: &str,
content: &str,
message: &str,
) {
std::fs::write(worktree_path.join(filename), content).unwrap();
self.run_git_in(worktree_path, &["add", filename]);
self.run_git_in(worktree_path, &["commit", "-m", message]);
}
pub fn create_branch(&self, branch_name: &str) {
self.run_git(&["branch", branch_name]);
}
pub fn push_branch(&self, branch_name: &str) {
self.run_git(&["push", "origin", branch_name]);
}
pub fn detach_head(&self) {
self.detach_head_at(&self.root);
}
pub fn detach_head_in_worktree(&self, name: &str) {
let worktree_path = self.worktree_path(name);
self.detach_head_at(worktree_path);
}
fn detach_head_at(&self, path: &Path) {
let sha = self.head_sha_in(path);
self.run_git_in(path, &["checkout", "--detach", &sha]);
}
pub fn lock_worktree(&self, name: &str, reason: Option<&str>) {
let worktree_path = self.worktree_path(name);
let worktree_str = worktree_path.to_str().unwrap();
match reason {
Some(r) => self.run_git(&["worktree", "lock", "--reason", r, worktree_str]),
None => self.run_git(&["worktree", "lock", worktree_str]),
}
}
pub fn setup_remote(&mut self, default_branch: &str) {
self.setup_custom_remote("origin", default_branch);
}
pub fn setup_custom_remote(&mut self, remote_name: &str, default_branch: &str) {
if remote_name == "origin" && self.remote.is_some() {
self.run_git(&["remote", "set-head", "origin", default_branch]);
return;
}
let remote_path = self.temp_dir.path().join(format!("{}.git", remote_name));
if remote_path.exists() {
self.remote = Some(canonicalize(&remote_path).unwrap());
return;
}
std::fs::create_dir(&remote_path).unwrap();
self.run_git_in(
&remote_path,
&["init", "--bare", "--initial-branch", default_branch],
);
let remote_path = canonicalize(&remote_path).unwrap();
let remote_path_str = remote_path.to_str().unwrap();
self.run_git(&["remote", "add", remote_name, remote_path_str]);
self.run_git(&["push", "-u", remote_name, default_branch]);
self.run_git(&["remote", "set-head", remote_name, default_branch]);
self.remote = Some(remote_path);
}
pub fn clear_origin_head(&self) {
self.run_git(&["remote", "set-head", "origin", "--delete"]);
}
pub fn has_origin_head(&self) -> bool {
self.git_command()
.args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
.run()
.unwrap()
.status
.success()
}
pub fn switch_primary_to(&self, branch: &str) {
self.run_git(&["switch", "-c", branch]);
}
pub fn current_branch(&self) -> String {
self.git_output(&["branch", "--show-current"])
}
pub fn setup_mock_gh(&mut self) {
self.setup_mock_gh_with_ci_data("[]", "[]");
}
pub fn setup_mock_ci_tools_unauthenticated(&mut self) {
let mock_bin = self.temp_dir.path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("auth", MockResponse::exit(1))
.write(&mock_bin);
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("auth", MockResponse::exit(1))
.write(&mock_bin);
self.mock_bin_path = Some(mock_bin);
}
pub fn setup_mock_claude_installed(&mut self) {
self.claude_installed = true;
}
pub fn setup_plugin_installed(temp_home: &std::path::Path) {
let plugins_dir = temp_home.join(".claude/plugins");
std::fs::create_dir_all(&plugins_dir).unwrap();
std::fs::write(
plugins_dir.join("installed_plugins.json"),
r#"{"version":2,"plugins":{"worktrunk@worktrunk":[{"scope":"user"}]}}"#,
)
.unwrap();
}
pub fn setup_statusline_configured(temp_home: &std::path::Path) {
let claude_dir = temp_home.join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("settings.json"),
r#"{"statusLine":{"type":"command","command":"wt list statusline --format=claude-code"}}"#,
)
.unwrap();
}
pub fn setup_mock_opencode_installed(&mut self) {
self.opencode_installed = true;
}
pub fn setup_opencode_plugin_installed(temp_home: &std::path::Path) {
let plugins_dir = temp_home.join("opencode-config/plugins");
std::fs::create_dir_all(&plugins_dir).unwrap();
std::fs::write(
plugins_dir.join("worktrunk.ts"),
include_str!("../../dev/opencode-plugin.ts"),
)
.unwrap();
}
pub fn setup_mock_claude_with_plugins(&mut self) {
let mock_bin = self
.mock_bin_path
.as_ref()
.expect("call setup_mock_ci_tools_unauthenticated() first");
MockConfig::new("claude")
.command("plugin marketplace", MockResponse::exit(0))
.command("plugin install", MockResponse::exit(0))
.command("plugin uninstall", MockResponse::exit(0))
.write(mock_bin);
self.claude_installed = true;
}
pub fn setup_mock_claude_with_plugins_failing(&mut self) {
let mock_bin = self
.mock_bin_path
.as_ref()
.expect("call setup_mock_ci_tools_unauthenticated() first");
MockConfig::new("claude")
.command(
"plugin marketplace",
MockResponse::exit(1).with_stderr("error: network timeout\n"),
)
.command(
"plugin install",
MockResponse::exit(1).with_stderr("error: install failed\n"),
)
.command(
"plugin uninstall",
MockResponse::exit(1).with_stderr("error: uninstall failed\n"),
)
.write(mock_bin);
self.claude_installed = true;
}
pub fn setup_mock_gh_with_ci_data(&mut self, pr_json: &str, run_json: &str) {
let mock_bin = self.temp_dir.path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
std::fs::write(mock_bin.join("pr_data.json"), pr_json).unwrap();
std::fs::write(mock_bin.join("run_data.json"), run_json).unwrap();
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("auth", MockResponse::exit(0))
.command("pr", MockResponse::file("pr_data.json"))
.command("run", MockResponse::file("run_data.json"))
.write(&mock_bin);
MockConfig::new("glab")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
self.mock_bin_path = Some(mock_bin);
}
pub fn setup_mock_glab_with_ci_data(&mut self, mr_json: &str, project_id: Option<u64>) {
let mock_bin = self.temp_dir.path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
std::fs::write(mock_bin.join("mr_list_data.json"), mr_json).unwrap();
let mut mock_config = MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("auth", MockResponse::exit(0))
.command("mr list", MockResponse::file("mr_list_data.json"));
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(mr_json)
&& let Some(arr) = parsed.as_array()
{
for mr in arr {
if let Some(iid) = mr.get("iid").and_then(|v| v.as_u64()) {
let filename = format!("mr_view_{}.json", iid);
let json = serde_json::to_string(mr).unwrap_or_default();
std::fs::write(mock_bin.join(&filename), json).unwrap();
mock_config = mock_config
.command(&format!("mr view {}", iid), MockResponse::file(&filename));
}
}
}
let project_id_response = match project_id {
Some(id) => format!(r#"{{"id": {}}}"#, id),
None => r#"{"error": "not found"}"#.to_string(),
};
mock_config
.command("repo", MockResponse::output(&project_id_response))
.command("ci", MockResponse::output("[]"))
.write(&mock_bin);
MockConfig::new("gh")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
self.mock_bin_path = Some(mock_bin);
}
pub fn setup_mock_glab_with_failing_mr_view(&mut self, mr_json: &str, project_id: Option<u64>) {
let mock_bin = self.temp_dir.path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
std::fs::write(mock_bin.join("mr_list_data.json"), mr_json).unwrap();
let project_id_response = match project_id {
Some(id) => format!(r#"{{"id": {}}}"#, id),
None => r#"{"error": "not found"}"#.to_string(),
};
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("auth", MockResponse::exit(0))
.command("mr list", MockResponse::file("mr_list_data.json"))
.command("repo", MockResponse::output(&project_id_response))
.command("ci", MockResponse::output("[]"))
.write(&mock_bin);
MockConfig::new("gh")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
self.mock_bin_path = Some(mock_bin);
}
pub fn setup_mock_glab_with_ci_rate_limit(&mut self, project_id: Option<u64>) {
let mock_bin = self.temp_dir.path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
let project_id_response = match project_id {
Some(id) => format!(r#"{{"id": {}}}"#, id),
None => r#"{"error": "not found"}"#.to_string(),
};
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("auth", MockResponse::exit(0))
.command("mr list", MockResponse::output("[]")) .command("repo", MockResponse::output(&project_id_response))
.command(
"ci",
MockResponse::stderr("API rate limit exceeded").with_exit_code(1),
)
.write(&mock_bin);
MockConfig::new("gh")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
self.mock_bin_path = Some(mock_bin);
}
pub fn configure_mock_commands(&self, cmd: &mut Command) {
if let Some(mock_bin) = &self.mock_bin_path {
cmd.env("MOCK_CONFIG_DIR", mock_bin);
let (path_var_name, current_path) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));
let mut paths: Vec<PathBuf> = current_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.clone());
let new_path = std::env::join_paths(&paths).unwrap();
cmd.env(&path_var_name, new_path);
}
if self.claude_installed {
cmd.env("WORKTRUNK_TEST_CLAUDE_INSTALLED", "1");
}
if self.opencode_installed {
cmd.env("WORKTRUNK_TEST_OPENCODE_INSTALLED", "1");
}
}
pub fn set_marker(&self, branch: &str, marker: &str) {
let config_key = format!("worktrunk.state.{branch}.marker");
let json_value = format!(r#"{{"marker":"{}","set_at":{}}}"#, marker, TEST_EPOCH);
self.git_command()
.args(["config", &config_key, &json_value])
.run()
.unwrap();
}
}
impl TestRepoBase for TestRepo {
fn git_config_path(&self) -> &Path {
&self.git_config_path
}
}
pub struct BareRepoTest {
temp_dir: tempfile::TempDir,
bare_repo_path: PathBuf,
test_config_path: PathBuf,
test_approvals_path: PathBuf,
git_config_path: PathBuf,
}
impl BareRepoTest {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
let temp_dir = tempfile::TempDir::new().unwrap();
let bare_repo_path = temp_dir.path().join("repo");
let test_config_path = temp_dir.path().join("test-config.toml");
let test_approvals_path = temp_dir.path().join("test-approvals.toml");
let git_config_path = temp_dir.path().join("test-gitconfig");
write_test_gitconfig(&git_config_path);
let mut test = Self {
temp_dir,
bare_repo_path,
test_config_path,
test_approvals_path,
git_config_path,
};
let output = configure_git_env(Cmd::new("git"), &test.git_config_path)
.args(["init", "--bare", "--initial-branch", "main"])
.arg(test.bare_repo_path.to_str().unwrap())
.run()
.unwrap();
if !output.status.success() {
panic!(
"Failed to init bare repo:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
test.bare_repo_path = canonicalize(&test.bare_repo_path).unwrap();
std::fs::write(&test.test_config_path, "worktree-path = \"{{ branch }}\"\n").unwrap();
test
}
pub fn bare_repo_path(&self) -> &Path {
&self.bare_repo_path
}
pub fn config_path(&self) -> &Path {
&self.test_config_path
}
pub fn temp_path(&self) -> &Path {
self.temp_dir.path()
}
pub fn create_worktree(&self, branch: &str, worktree_name: &str) -> PathBuf {
let worktree_path = self.bare_repo_path.join(worktree_name);
let output = self
.git_command(&self.bare_repo_path)
.args([
"worktree",
"add",
"-b",
branch,
worktree_path.to_str().unwrap(),
])
.run()
.unwrap();
if !output.status.success() {
panic!(
"Failed to create worktree:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
canonicalize(&worktree_path).unwrap()
}
pub fn configure_wt_cmd(&self, cmd: &mut Command) {
self.configure_git_cmd(cmd);
cmd.env("WORKTRUNK_CONFIG_PATH", &self.test_config_path)
.env(
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"/etc/xdg/worktrunk/config.toml",
)
.env("WORKTRUNK_APPROVALS_PATH", &self.test_approvals_path)
.env_remove("NO_COLOR")
.env_remove("CLICOLOR_FORCE");
}
pub fn wt_command(&self) -> Command {
let mut cmd = wt_command();
self.configure_wt_cmd(&mut cmd);
cmd
}
}
impl TestRepoBase for BareRepoTest {
fn git_config_path(&self) -> &Path {
&self.git_config_path
}
}
pub fn make_snapshot_cmd_with_global_flags(
repo: &TestRepo,
subcommand: &str,
args: &[&str],
cwd: Option<&Path>,
global_flags: &[&str],
) -> Command {
let mut cmd = Command::new(wt_bin());
repo.configure_wt_cmd(&mut cmd);
cmd.args(global_flags)
.arg(subcommand)
.args(args)
.current_dir(cwd.unwrap_or(repo.root_path()));
cmd
}
pub fn make_snapshot_cmd(
repo: &TestRepo,
subcommand: &str,
args: &[&str],
cwd: Option<&Path>,
) -> Command {
make_snapshot_cmd_with_global_flags(repo, subcommand, args, cwd, &[])
}
pub fn resolve_git_common_dir(worktree_path: &Path) -> PathBuf {
let repo = Repository::at(worktree_path).unwrap();
repo.git_common_dir().to_path_buf()
}
pub fn validate_ansi_codes(text: &str) -> Vec<String> {
let mut warnings = Vec::new();
let nested_pattern = regex::Regex::new(
r"(\x1b\[[0-9;]+m)([^\x1b]+)(\x1b\[[0-9;]+m)([^\x1b]*?)(\x1b\[0m)(\s*[^\s\x1b]+)(\x1b\[0m)",
)
.unwrap();
for cap in nested_pattern.captures_iter(text) {
let content_after_reset = cap[6].trim();
if !content_after_reset.is_empty()
&& content_after_reset.chars().any(|c| c.is_alphanumeric())
{
warnings.push(format!(
"Nested color reset detected: content '{}' appears after inner reset but before outer reset - it will lose the outer color",
content_after_reset
));
}
}
warnings
}
#[derive(Debug, Clone)]
pub struct ExponentialBackoff {
pub initial_ms: u64,
pub max_ms: u64,
#[cfg_attr(windows, allow(dead_code))] pub timeout: std::time::Duration,
}
impl Default for ExponentialBackoff {
fn default() -> Self {
Self {
initial_ms: 10,
max_ms: 500,
timeout: std::time::Duration::from_secs(5),
}
}
}
impl ExponentialBackoff {
pub fn sleep(&self, attempt: u32) {
let ms = (self.initial_ms * (1u64 << attempt.min(20))).min(self.max_ms);
std::thread::sleep(std::time::Duration::from_millis(ms));
}
}
fn exponential_sleep(attempt: u32) {
ExponentialBackoff::default().sleep(attempt);
}
fn worktree_contents_removed(path: &Path) -> bool {
match path.read_dir() {
Ok(mut entries) => entries.next().is_none(), Err(_) => true, }
}
pub fn assert_worktree_removed(path: &Path) {
assert!(
worktree_contents_removed(path),
"Worktree contents should be removed (empty placeholder OK): {}",
path.display()
);
}
pub fn wait_for_worktree_removed(path: &Path) {
wait_for(
&format!("worktree contents removed: {}", path.display()),
|| worktree_contents_removed(path),
);
}
pub fn wait_for_file(path: &Path) {
let start = std::time::Instant::now();
let mut attempt = 0;
while start.elapsed() < BG_TIMEOUT {
if path.exists() {
return;
}
exponential_sleep(attempt);
attempt += 1;
}
panic!(
"File was not created within {:?}: {}",
BG_TIMEOUT,
path.display()
);
}
pub fn wait_for_file_count(dir: &Path, extension: &str, expected_count: usize) {
let start = std::time::Instant::now();
let mut attempt = 0;
while start.elapsed() < BG_TIMEOUT {
if count_files_recursive(dir, extension) >= expected_count {
return;
}
exponential_sleep(attempt);
attempt += 1;
}
panic!(
"Expected {} .{} files in {:?} within {:?}",
expected_count, extension, dir, BG_TIMEOUT
);
}
fn count_files_recursive(dir: &Path, extension: &str) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut count = 0;
for entry in entries.filter_map(|e| e.ok()) {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
count += count_files_recursive(&path, extension);
} else if path.extension().and_then(|s| s.to_str()) == Some(extension) {
count += 1;
}
}
count
}
pub fn wait_for_file_content(path: &Path) {
let start = std::time::Instant::now();
let mut attempt = 0;
while start.elapsed() < BG_TIMEOUT {
if std::fs::metadata(path).is_ok_and(|m| m.len() > 0) {
return;
}
exponential_sleep(attempt);
attempt += 1;
}
panic!(
"File remained empty within {:?}: {}",
BG_TIMEOUT,
path.display()
);
}
pub fn wait_for_file_lines(path: &Path, expected_lines: usize) {
let start = std::time::Instant::now();
let mut attempt = 0;
while start.elapsed() < BG_TIMEOUT {
if let Ok(content) = std::fs::read_to_string(path) {
let line_count = content.lines().count();
if line_count >= expected_lines {
return;
}
}
exponential_sleep(attempt);
attempt += 1;
}
let actual = std::fs::read_to_string(path)
.map(|c| c.lines().count())
.unwrap_or(0);
panic!(
"File did not reach {} lines within {:?} (got {}): {}",
expected_lines,
BG_TIMEOUT,
actual,
path.display()
);
}
pub fn wait_for_valid_json(path: &Path) -> serde_json::Value {
let start = std::time::Instant::now();
let mut attempt = 0;
let mut last_error = String::new();
while start.elapsed() < BG_TIMEOUT {
if let Ok(content) = std::fs::read_to_string(path) {
match serde_json::from_str(&content) {
Ok(json) => return json,
Err(e) => last_error = format!("{e} (content: {content})"),
}
}
exponential_sleep(attempt);
attempt += 1;
}
panic!(
"File did not contain valid JSON within {:?}: {}\nLast error: {}",
BG_TIMEOUT,
path.display(),
last_error
);
}
pub fn wait_for(description: &str, mut check: impl FnMut() -> bool) {
let start = std::time::Instant::now();
let mut attempt = 0;
while start.elapsed() < BG_TIMEOUT {
if check() {
return;
}
exponential_sleep(attempt);
attempt += 1;
}
panic!("Condition not met within {:?}: {}", BG_TIMEOUT, description);
}
fn unix_to_iso8601(timestamp: i64) -> String {
let days_since_epoch = timestamp / 86400;
let seconds_in_day = timestamp % 86400;
let hours = seconds_in_day / 3600;
let minutes = (seconds_in_day % 3600) / 60;
let seconds = seconds_in_day % 60;
let mut year = 1970i64;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months: [i64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1;
for &days in &days_in_months {
if remaining_days < days {
break;
}
remaining_days -= days;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unix_to_iso8601() {
assert_eq!(unix_to_iso8601(1735689600), "2025-01-01T00:00:00Z");
assert_eq!(unix_to_iso8601(1735776000), "2025-01-02T00:00:00Z");
assert_eq!(unix_to_iso8601(1735603200), "2024-12-31T00:00:00Z");
assert_eq!(unix_to_iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(unix_to_iso8601(1709164800), "2024-02-29T00:00:00Z");
}
#[test]
fn test_validate_ansi_codes_no_leak() {
let output = "\x1b[36mtext\x1b[0m (stats)";
assert!(validate_ansi_codes(output).is_empty());
let output = "\x1b[36mtext\x1b[0m (\x1b[32mnested\x1b[0m)";
assert!(validate_ansi_codes(output).is_empty());
}
#[test]
fn test_validate_ansi_codes_detects_leak() {
let output = "\x1b[36mtext (\x1b[32mnested\x1b[0m more)\x1b[0m";
let warnings = validate_ansi_codes(output);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("more"));
}
#[test]
fn test_validate_ansi_codes_ignores_punctuation() {
let output = "\x1b[36mtext (\x1b[32mnested\x1b[0m)\x1b[0m";
let warnings = validate_ansi_codes(output);
assert!(warnings.is_empty() || !warnings[0].contains("loses"));
}
}