#![ allow( clippy::doc_markdown ) ] mod cli_binary_test_helpers;
use cli_binary_test_helpers::{ run_cli, run_cli_with_env };
use std::process::Command;
#[ cfg( unix ) ]
use std::os::unix::fs::PermissionsExt;
#[ test ]
fn ec1_timeout_help_listed()
{
let out = run_cli( &[ "--help" ] );
assert!( out.status.success(), "clr --help must exit 0" );
let stdout = String::from_utf8_lossy( &out.stdout );
assert!(
stdout.contains( "--timeout" ),
"`clr --help` must list --timeout for run/ask. Got:\n{stdout}"
);
}
#[ test ]
fn ec2_timeout_zero_dry_run()
{
let out = run_cli( &[ "--timeout", "0", "--dry-run", "task" ] );
assert!(
out.status.success(),
"exit must be 0. stderr: {}",
String::from_utf8_lossy( &out.stderr )
);
}
#[ test ]
fn ec3_timeout_nonzero_dry_run()
{
let out = run_cli( &[ "--timeout", "30", "--dry-run", "task" ] );
assert!(
out.status.success(),
"exit must be 0. stderr: {}",
String::from_utf8_lossy( &out.stderr )
);
}
#[ test ]
fn ec4_clr_timeout_env_var_accepted()
{
let out = run_cli_with_env(
&[ "--dry-run", "task" ],
&[ ( "CLR_TIMEOUT", "10" ) ],
);
assert!(
out.status.success(),
"CLR_TIMEOUT env var must be accepted. stderr: {}",
String::from_utf8_lossy( &out.stderr )
);
}
#[ test ]
fn ec5_timeout_cli_wins_over_env()
{
let out = run_cli_with_env(
&[ "--timeout", "60", "--dry-run", "task" ],
&[ ( "CLR_TIMEOUT", "5" ) ],
);
assert!(
out.status.success(),
"CLI value must win over env var. stderr: {}",
String::from_utf8_lossy( &out.stderr )
);
}
#[ test ]
fn ec6_clr_timeout_invalid_ignored()
{
let out = run_cli_with_env(
&[ "--dry-run", "task" ],
&[ ( "CLR_TIMEOUT", "abc" ) ],
);
assert!(
out.status.success(),
"invalid CLR_TIMEOUT must be silently ignored. stderr: {}",
String::from_utf8_lossy( &out.stderr )
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec7_timeout_fires_kills_subprocess()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nsleep 30\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let start = std::time::Instant::now();
let out = Command::new( bin )
.args( [ "-p", "--timeout", "1", "--max-sessions", "0", "--retry-override", "0", "x" ] )
.env( "PATH", &new_path )
.output()
.expect( "invoke clr" );
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some( 4 ),
"exit must be 4 on timeout (TSK-202: timeout uses exit 4, not exit 2). Got: {:?}", out.status.code()
);
assert!(
elapsed.as_secs() < 5,
"watchdog must fire within ~2s; elapsed {elapsed:?} suggests timeout not working"
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
stderr.to_lowercase().contains( "timeout" ),
"stderr must contain 'timeout'. Got:\n{stderr}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec8_no_timeout_when_subprocess_exits_fast()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nprintf 'done'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let out = Command::new( bin )
.args( [ "-p", "--timeout", "30", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.output()
.expect( "invoke clr" );
assert!(
out.status.success(),
"exit must be 0. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"no timeout message when subprocess exits before deadline. Got:\n{stderr}"
);
}
#[ test ]
fn ec_timeout_default_constant_value()
{
let src = include_str!( "../src/cli/execution.rs" );
assert!(
src.contains( "DEFAULT_PRINT_TIMEOUT_SECS : u32 = 3600" ),
"DEFAULT_PRINT_TIMEOUT_SECS must be defined as u32 = 3600 in src/cli/execution.rs"
);
assert!(
src.contains( "unwrap_or( DEFAULT_PRINT_TIMEOUT_SECS )" ),
"DEFAULT_PRINT_TIMEOUT_SECS must appear in unwrap_or() (inside default_print_timeout() helper)"
);
assert!(
src.contains( "unwrap_or( default_print_timeout() )" ),
"run_print_mode() call site must use default_print_timeout(), not the constant directly"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_default_no_fire()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nprintf 'ok'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let out = Command::new( bin )
.args( [ "-p", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.env_remove( "CLR_TIMEOUT" )
.output()
.expect( "invoke clr" );
assert!(
out.status.success(),
"exit must be 0: fast subprocess under default 3600s watchdog. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"no timeout message: 3600s default watchdog must not fire on fast subprocess. Got:\n{stderr}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_default_activates_watchdog()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nsleep 2\nprintf 'ok'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let start = std::time::Instant::now();
let out = Command::new( bin )
.args( [ "-p", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.env_remove( "CLR_TIMEOUT" )
.output()
.expect( "invoke clr" );
let elapsed = start.elapsed();
assert!(
out.status.success(),
"exit must be 0: 2s subprocess completes before 3600s default watchdog. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"3600s default watchdog must not fire on a 2s subprocess. Got:\n{stderr}"
);
assert!(
elapsed.as_secs() < 10,
"test must complete in <10s (subprocess sleeps 2s); elapsed {elapsed:?}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_explicit_above_default()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nprintf 'ok'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let out = Command::new( bin )
.args( [ "-p", "--timeout", "7200", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.env_remove( "CLR_TIMEOUT" )
.output()
.expect( "invoke clr" );
assert!(
out.status.success(),
"exit must be 0 with --timeout 7200 and fast subprocess. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"no timeout message with --timeout 7200 and fast subprocess. Got:\n{stderr}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_unlimited_flag()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nprintf 'ok'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let out = Command::new( bin )
.args( [ "-p", "--timeout", "0", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.env_remove( "CLR_TIMEOUT" )
.output()
.expect( "invoke clr" );
assert!(
out.status.success(),
"--timeout 0 must opt out of 3600s default; fast subprocess exits 0. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"--timeout 0 means unlimited — no timeout message expected. Got:\n{stderr}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_unlimited_env()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nprintf 'ok'\nexit 0\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let out = Command::new( bin )
.args( [ "-p", "--max-sessions", "0", "x" ] )
.env( "PATH", &new_path )
.env( "CLR_TIMEOUT", "0" )
.output()
.expect( "invoke clr" );
assert!(
out.status.success(),
"CLR_TIMEOUT=0 must opt out of 3600s default; fast subprocess exits 0. exit={:?} stderr={}",
out.status.code(),
String::from_utf8_lossy( &out.stderr )
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
!stderr.to_lowercase().contains( "timeout" ),
"CLR_TIMEOUT=0 means unlimited — no timeout message expected. Got:\n{stderr}"
);
}
#[ cfg( unix ) ]
#[ test ]
fn ec_timeout_default_kills()
{
let tmp = tempfile::tempdir().expect( "create temp dir" );
let fake = tmp.path().join( "claude" );
std::fs::write( &fake, b"#!/bin/sh\nsleep 30\n" ).expect( "write fake claude" );
std::fs::set_permissions( &fake, std::fs::Permissions::from_mode( 0o755 ) )
.expect( "chmod fake claude" );
let old_path = std::env::var( "PATH" ).unwrap_or_default();
let new_path = format!( "{}:{old_path}", tmp.path().display() );
let bin = env!( "CARGO_BIN_EXE_clr" );
let start = std::time::Instant::now();
let out = Command::new( bin )
.args( [ "-p", "--max-sessions", "0", "--retry-override", "0", "x" ] )
.env( "PATH", &new_path )
.env( "_CLR_DEFAULT_TIMEOUT", "2" )
.env_remove( "CLR_TIMEOUT" )
.output()
.expect( "invoke clr" );
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some( 4 ),
"exit must be 4: default watchdog fired via _CLR_DEFAULT_TIMEOUT=2. Got: {:?}",
out.status.code()
);
assert!(
elapsed.as_secs() < 10,
"default watchdog (2s) must fire within ~5s; elapsed {elapsed:?} — kill path broken"
);
let stderr = String::from_utf8_lossy( &out.stderr );
assert!(
stderr.to_lowercase().contains( "timeout" ),
"stderr must contain 'timeout' when default watchdog fires. Got:\n{stderr}"
);
}