#[cfg(test)]
use super::*;
use crate::tools::common::summary_cursor_conflict;
use crate::tools::exec_command::{build_exec_command, handle_output_persist, strip_cd_prefix};
use crate::validation::validate_path_relative_to;
use aptu_coder_core::traversal;
use regex::Regex;
fn make_analyzer() -> CodeAnalyzer {
let peer = Arc::new(TokioMutex::new(None));
let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
CodeAnalyzer::new(
peer,
log_level_filter,
rx,
crate::metrics::MetricsSender(metrics_tx),
)
}
#[test]
fn test_summary_cursor_conflict() {
assert!(summary_cursor_conflict(Some(true), Some("cursor")));
assert!(!summary_cursor_conflict(Some(true), None));
assert!(!summary_cursor_conflict(None, Some("x")));
assert!(!summary_cursor_conflict(None, None));
}
#[tokio::test]
async fn test_validate_impl_only_non_rust_returns_invalid_params() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
let analyzer = make_analyzer();
let entries: Vec<traversal::WalkEntry> =
traversal::walk_directory(dir.path(), None).unwrap_or_default();
let result = crate::tools::analyze_symbol::validate_impl_only(&entries);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
drop(analyzer); }
#[tokio::test]
async fn test_no_cache_meta_on_analyze_directory_result() {
use aptu_coder_core::types::AnalyzeDirectoryParams;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let analyzer = make_analyzer();
let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
"path": dir.path().to_str().unwrap(),
}))
.unwrap();
let ct = tokio_util::sync::CancellationToken::new();
let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
let meta = no_cache_meta();
assert_eq!(
meta.0.get("cache_hint").and_then(|v| v.as_str()),
Some("no-cache"),
);
drop(arc_output);
}
#[test]
fn test_complete_path_completions_returns_suggestions() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
let suggestions = completion::path_completions(workspace_root, "aptu-");
assert!(
!suggestions.is_empty(),
"expected completions for prefix 'aptu-' in workspace root"
);
}
#[tokio::test]
async fn test_handle_overview_mode_no_summary_block() {
use aptu_coder_core::types::AnalyzeDirectoryParams;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
let peer = Arc::new(TokioMutex::new(None));
let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
let analyzer = CodeAnalyzer::new(
peer,
log_level_filter,
rx,
crate::metrics::MetricsSender(metrics_tx),
);
let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
"path": tmp.path().to_str().unwrap(),
}))
.unwrap();
let ct = tokio_util::sync::CancellationToken::new();
let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
let formatted = &output.formatted;
assert!(
formatted.contains("SUMMARY:"),
"summary=None with small output must emit SUMMARY: block (tree output); got: {}",
&formatted[..formatted.len().min(300)]
);
assert!(
formatted.contains("PATH [LOC, FUNCTIONS, CLASSES]"),
"summary=None with small output must emit PATH section header (tree output); got: {}",
&formatted[..formatted.len().min(300)]
);
assert!(
!formatted.contains("PAGINATED:"),
"summary=None must NOT emit PAGINATED: header; got: {}",
&formatted[..formatted.len().min(300)]
);
}
#[tokio::test]
async fn test_analyze_directory_summary_false_forces_pagination() {
use aptu_coder_core::types::AnalyzeDirectoryParams;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), "fn foo() {}").unwrap();
let peer = Arc::new(TokioMutex::new(None));
let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
let analyzer = CodeAnalyzer::new(
peer,
log_level_filter,
rx,
crate::metrics::MetricsSender(metrics_tx),
);
let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
"path": tmp.path().to_str().unwrap(),
"summary": false,
}))
.unwrap();
let ct = tokio_util::sync::CancellationToken::new();
let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
assert!(
output.formatted.len() <= SIZE_LIMIT,
"test precondition: output must be small; got {} chars",
output.formatted.len()
);
let use_paginated = params.output_control.summary == Some(false);
assert!(use_paginated, "summary=false must set use_paginated=true");
assert!(
!output.formatted.contains("PAGINATED:"),
"handle_overview_mode returns format_structure (tree); PAGINATED: must not appear"
);
assert!(
output.formatted.contains("SUMMARY:"),
"handle_overview_mode returns format_structure (tree); SUMMARY: must appear"
);
}
#[tokio::test]
async fn test_analyze_directory_cache_hit_metrics() {
use aptu_coder_core::types::AnalyzeDirectoryParams;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
let analyzer = make_analyzer();
let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
"path": dir.path().to_str().unwrap(),
}))
.unwrap();
let ct1 = tokio_util::sync::CancellationToken::new();
let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
let ct2 = tokio_util::sync::CancellationToken::new();
let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
}
#[test]
fn test_analyze_module_cache_hit_metrics() {
use std::io::Write as _;
use tempfile::NamedTempFile;
let cwd = std::env::current_dir().unwrap();
let mut f = NamedTempFile::with_suffix_in(".rs", &cwd).unwrap();
write!(f, "use std::io;\nfn bar() {{}}\n").unwrap();
f.flush().unwrap();
let result = analyze::analyze_module_file(f.path().to_str().unwrap());
let module_info = result.expect("analyze_module_file must succeed");
assert_eq!(
module_info.functions.len(),
1,
"expected exactly one function"
);
assert_eq!(module_info.functions[0].name, "bar");
assert_eq!(module_info.imports.len(), 1, "expected exactly one import");
assert!(
module_info.imports[0].module.contains("std"),
"import module must contain 'std', got: {}",
module_info.imports[0].module
);
}
#[test]
fn test_analyze_symbol_import_lookup_invalid_params() {
let result = crate::tools::analyze_symbol::validate_import_lookup(Some(true), "");
assert!(
result.is_err(),
"import_lookup=true with empty symbol must return Err"
);
let err = result.unwrap_err();
assert_eq!(
err.code,
rmcp::model::ErrorCode::INVALID_PARAMS,
"expected INVALID_PARAMS; got {:?}",
err.code
);
}
#[tokio::test]
async fn test_analyze_symbol_import_lookup_found() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("main.rs"),
"use std::collections::HashMap;\nfn main() {}\n",
)
.unwrap();
let entries = traversal::walk_directory(dir.path(), None).unwrap();
let output =
analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
assert!(
output.formatted.contains("MATCHES: 1"),
"expected 1 match; got: {}",
output.formatted
);
assert!(
output.formatted.contains("main.rs"),
"expected main.rs in output; got: {}",
output.formatted
);
}
#[tokio::test]
async fn test_analyze_symbol_import_lookup_empty() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let entries = traversal::walk_directory(dir.path(), None).unwrap();
let output =
analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
assert!(
output.formatted.contains("MATCHES: 0"),
"expected 0 matches; got: {}",
output.formatted
);
}
#[tokio::test]
async fn test_analyze_directory_git_ref_non_git_repo() {
use aptu_coder_core::traversal::changed_files_from_git_ref;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
assert!(result.is_err(), "non-git dir must return an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("git"),
"error must mention git; got: {err_msg}"
);
}
#[tokio::test]
async fn test_analyze_directory_git_ref_filters_changed_files() {
use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
use std::collections::HashSet;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let changed_file = dir.path().join("changed.rs");
let unchanged_file = dir.path().join("unchanged.rs");
std::fs::write(&changed_file, "fn changed() {}").unwrap();
std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
let entries = traversal::walk_directory(dir.path(), None).unwrap();
let total_files = entries.iter().filter(|e| !e.is_dir).count();
assert_eq!(total_files, 2, "sanity: 2 files before filtering");
let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
changed.insert(changed_file.clone());
let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
assert_eq!(
filtered_files.len(),
1,
"only 1 file must remain after git_ref filter"
);
assert_eq!(
filtered_files[0].path, changed_file,
"the remaining file must be the changed one"
);
let _ = changed_files_from_git_ref;
}
#[tokio::test]
async fn test_handle_overview_mode_git_ref_filters_via_handler() {
use aptu_coder_core::types::AnalyzeDirectoryParams;
use std::process::Command;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let repo = dir.path();
let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
let mut cmd = std::process::Command::new("git");
cmd.args(["-c", "core.hooksPath=/dev/null"]);
cmd.args(args);
cmd.current_dir(repo_path);
let out = cmd.output().unwrap();
assert!(out.status.success(), "{out:?}");
};
git_no_hook(repo, &["init"]);
git_no_hook(
repo,
&[
"-c",
"user.email=ci@example.com",
"-c",
"user.name=CI",
"commit",
"--allow-empty",
"-m",
"initial",
],
);
std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
git_no_hook(repo, &["add", "file_a.rs"]);
git_no_hook(
repo,
&[
"-c",
"user.email=ci@example.com",
"-c",
"user.name=CI",
"commit",
"-m",
"add a",
],
);
std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
git_no_hook(repo, &["add", "file_b.rs"]);
git_no_hook(
repo,
&[
"-c",
"user.email=ci@example.com",
"-c",
"user.name=CI",
"commit",
"-m",
"add b",
],
);
let canon_repo = std::fs::canonicalize(repo).unwrap();
let analyzer = make_analyzer();
let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
"path": canon_repo.to_str().unwrap(),
"git_ref": "HEAD~1",
}))
.unwrap();
let ct = tokio_util::sync::CancellationToken::new();
let (arc_output, _cache_hit) = analyzer
.handle_overview_mode(¶ms, ct)
.await
.expect("handle_overview_mode with git_ref must succeed");
let formatted = &arc_output.formatted;
assert!(
formatted.contains("file_b.rs"),
"git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
);
assert!(
!formatted.contains("file_a.rs"),
"git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
);
}
#[test]
fn test_validate_path_rejects_absolute_path_outside_cwd() {
let result = validate_path("/etc/passwd", true);
assert!(
result.is_err(),
"validate_path should reject /etc/passwd (outside CWD)"
);
let err = result.unwrap_err();
let err_msg = err.message.to_lowercase();
assert!(
err_msg.contains("outside") || err_msg.contains("not found"),
"Error message should mention 'outside' or 'not found': {}",
err.message
);
}
#[test]
fn test_validate_path_accepts_relative_path_in_cwd() {
let result = validate_path("Cargo.toml", true);
assert!(
result.is_ok(),
"validate_path should accept Cargo.toml (exists in CWD)"
);
}
#[test]
fn test_validate_path_creates_parent_for_nonexistent_file() {
let cwd = std::env::current_dir().expect("should get cwd");
let parent = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
let parent_path = parent.path().to_path_buf();
let child = parent_path.join("new_file.txt");
let child_str = child.to_str().expect("path should be valid UTF-8");
let result = validate_path(child_str, false);
assert!(
result.is_ok(),
"validate_path should accept non-existent file with existing parent (require_exists=false)"
);
let path = result.unwrap();
let canonical_cwd = std::fs::canonicalize(&cwd).expect("should canonicalize cwd");
assert!(
path.starts_with(&canonical_cwd),
"Resolved path should be within CWD: {:?} should start with {:?}",
path,
canonical_cwd
);
}
#[test]
fn test_edit_overwrite_with_working_dir() {
let cwd = std::env::current_dir().expect("should get cwd");
let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
let temp_path = temp_dir.path();
let result = validate_path_relative_to("test_file.txt", false, temp_path);
assert!(
result.is_ok(),
"validate_path_relative_to should accept relative path in valid working_dir: {:?}",
result.err()
);
let resolved = result.unwrap();
assert!(
resolved.starts_with(temp_path),
"Resolved path should be within working_dir: {:?} should start with {:?}",
resolved,
temp_path
);
}
#[test]
fn test_validate_path_in_dir_accepts_outside_cwd() {
let temp_dir = std::env::temp_dir();
let canonical_temp_dir =
std::fs::canonicalize(&temp_dir).expect("should canonicalize temp_dir");
let result = validate_path_relative_to("probe.txt", false, &temp_dir);
assert!(
result.is_ok(),
"validate_path_relative_to should accept working_dir outside CWD: {:?}",
result.err()
);
let resolved = result.unwrap();
assert!(
resolved.starts_with(&canonical_temp_dir),
"Resolved path should be within working_dir: {:?} should start with {:?}",
resolved,
canonical_temp_dir
);
}
#[test]
fn test_edit_replace_with_working_dir() {
let cwd = std::env::current_dir().expect("should get cwd");
let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
let temp_path = temp_dir.path();
let file_path = temp_path.join("test.txt");
std::fs::write(&file_path, "hello world").expect("should write test file");
let result = validate_path_relative_to("test.txt", true, temp_path);
assert!(
result.is_ok(),
"validate_path_relative_to should find existing file in working_dir: {:?}",
result.err()
);
let resolved = result.unwrap();
assert_eq!(
resolved, file_path,
"Resolved path should match the actual file path"
);
}
#[test]
fn test_edit_overwrite_no_working_dir() {
let result = validate_path("Cargo.toml", true);
assert!(
result.is_ok(),
"validate_path should still work without working_dir"
);
}
#[test]
fn test_edit_overwrite_working_dir_is_file() {
let cwd = std::env::current_dir().expect("should get cwd");
let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
let temp_file = temp_dir.path().join("test_file.txt");
std::fs::write(&temp_file, "test content").expect("should write test file");
let result = validate_path_relative_to("some_file.txt", false, &temp_file);
assert!(
result.is_err(),
"validate_path_relative_to should reject a file as working_dir"
);
let err = result.unwrap_err();
let err_msg = err.message.to_lowercase();
assert!(
err_msg.contains("directory"),
"Error message should mention 'directory': {}",
err.message
);
}
#[test]
fn test_tool_annotations() {
let tools = CodeAnalyzer::list_tools();
let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
let exec_command = tools.iter().find(|t| t.name == "exec_command");
let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
let analyze_dir_annot = analyze_dir_tool
.annotations
.as_ref()
.expect("analyze_directory should have annotations");
assert_eq!(
analyze_dir_annot.read_only_hint,
Some(true),
"analyze_directory read_only_hint should be true"
);
assert_eq!(
analyze_dir_annot.destructive_hint,
Some(false),
"analyze_directory destructive_hint should be false"
);
let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
let exec_cmd_annot = exec_cmd_tool
.annotations
.as_ref()
.expect("exec_command should have annotations");
assert_eq!(
exec_cmd_annot.open_world_hint,
Some(true),
"exec_command open_world_hint should be true"
);
}
#[test]
fn test_exec_stdin_size_cap_validation() {
let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
assert!(
oversized_stdin.len() > STDIN_MAX_BYTES,
"test setup: oversized stdin should exceed 1 MB"
);
let max_stdin = "y".repeat(STDIN_MAX_BYTES);
assert_eq!(
max_stdin.len(),
STDIN_MAX_BYTES,
"test setup: max stdin should be exactly 1 MB"
);
}
#[tokio::test]
async fn test_exec_stdin_cat_roundtrip() {
let stdin_content = "hello world";
let mut child = tokio::process::Command::new("sh")
.arg("-c")
.arg("cat")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn cat");
if let Some(mut stdin_handle) = child.stdin.take() {
use tokio::io::AsyncWriteExt as _;
stdin_handle
.write_all(stdin_content.as_bytes())
.await
.expect("write stdin");
drop(stdin_handle);
}
let output = child.wait_with_output().await.expect("wait for cat");
let stdout_str = String::from_utf8_lossy(&output.stdout);
assert!(
stdout_str.contains(stdin_content),
"stdout should contain stdin content: {}",
stdout_str
);
}
#[tokio::test]
async fn test_exec_stdin_none_no_regression() {
let child = tokio::process::Command::new("sh")
.arg("-c")
.arg("echo hi")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn echo");
let output = child.wait_with_output().await.expect("wait for echo");
let stdout_str = String::from_utf8_lossy(&output.stdout);
assert!(
stdout_str.contains("hi"),
"stdout should contain echo output: {}",
stdout_str
);
}
#[test]
fn test_validate_path_in_dir_rejects_sibling_prefix() {
let cwd = std::env::current_dir().expect("should get cwd");
let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
let allowed = parent.path().join("allowed");
let sibling = parent.path().join("allowed_sibling");
std::fs::create_dir_all(&allowed).expect("should create allowed dir");
std::fs::create_dir_all(&sibling).expect("should create sibling dir");
let result = validate_path_relative_to("../allowed_sibling/secret.txt", false, &allowed);
assert!(
result.is_err(),
"validate_path_relative_to must reject a path resolving to a sibling directory \
sharing the working_dir name prefix (CVE-2025-53110 pattern)"
);
let err = result.unwrap_err();
let msg = err.message.to_lowercase();
assert!(
msg.contains("outside") || msg.contains("working"),
"Error should mention 'outside' or 'working', got: {}",
err.message
);
}
#[test]
fn test_validate_path_in_dir_nonexistent_deep_path() {
let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
let result = validate_path_relative_to("a/b/c/d/new.txt", false, temp_dir.path());
assert!(
result.is_err(),
"validate_path_relative_to should reject deeply nested non-existent path"
);
}
#[test]
fn test_validate_path_in_dir_nonexistent_with_existing_parent() {
let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
let sub = temp_dir.path().join("sub");
std::fs::create_dir_all(&sub).expect("should create sub dir");
let result = validate_path_relative_to("sub/new.txt", false, temp_dir.path());
assert!(
result.is_ok(),
"validate_path_relative_to should accept file in existing subdir: {:?}",
result.err()
);
let resolved = result.unwrap();
let canonical_sub = std::fs::canonicalize(&sub).expect("should canonicalize sub");
assert!(
resolved.starts_with(&canonical_sub),
"Resolved path should anchor at the existing sub/ dir: {resolved:?}"
);
assert_eq!(
resolved.file_name().and_then(|n| n.to_str()),
Some("new.txt"),
"File name component must be preserved"
);
}
#[test]
#[serial_test::serial]
fn test_file_cache_capacity_default() {
unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
let analyzer = make_analyzer();
assert_eq!(analyzer.cache.file_capacity(), 100);
}
#[test]
#[serial_test::serial]
fn test_file_cache_capacity_from_env() {
unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
let analyzer = make_analyzer();
unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
assert_eq!(analyzer.cache.file_capacity(), 42);
}
#[test]
fn test_exec_command_path_injected() {
let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
let cmd = build_exec_command("echo test", None, false, resolved_path);
let cmd_str = format!("{:?}", cmd);
assert!(
!cmd_str.contains("-l"),
"build_exec_command must not use -l on any platform"
);
assert!(
!cmd_str.is_empty(),
"build_exec_command should return a valid Command"
);
}
#[test]
fn test_exec_command_path_fallback() {
let cmd = build_exec_command("echo test", None, false, None);
let cmd_str = format!("{:?}", cmd);
assert!(
!cmd_str.contains("-l"),
"build_exec_command must not use -l on any platform"
);
assert!(
!cmd_str.is_empty(),
"build_exec_command should handle None resolved_path gracefully"
);
}
#[test]
fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
assert_eq!(
CacheTier::Miss.as_str(),
"miss",
"CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
);
assert!(
!matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
"CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
);
}
#[tokio::test]
async fn test_unsupported_extension_returns_success() {
let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
let unsupported_file = temp_dir.path().join("notes.txt");
std::fs::write(&unsupported_file, "line one\nline two\nline three").expect("should write file");
let analyzer = make_analyzer();
let mut params = AnalyzeFileParams::default();
params.path = unsupported_file.to_string_lossy().to_string();
let result = analyzer.handle_file_details_mode(¶ms).await;
assert!(
result.is_ok(),
"should succeed for unsupported extension; got: {:?}",
result
);
let (output, _tier) = result.unwrap();
assert_eq!(output.line_count, 3, "line_count must be 3");
assert!(
output.semantic.functions.is_empty(),
"functions must be empty"
);
assert!(output.semantic.classes.is_empty(), "classes must be empty");
assert!(output.semantic.imports.is_empty(), "imports must be empty");
}
#[tokio::test]
async fn test_unsupported_extension_fallback_note_in_formatted() {
let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
let unsupported_file = temp_dir.path().join("readme.txt");
std::fs::write(
&unsupported_file,
"This is a plain text file.\nSecond line.",
)
.expect("should write file");
let analyzer = make_analyzer();
let mut params = AnalyzeFileParams::default();
params.path = unsupported_file.to_string_lossy().to_string();
let (output, _tier) = analyzer
.handle_file_details_mode(¶ms)
.await
.expect("must succeed");
let lower = output.formatted.to_lowercase();
assert!(
lower.contains("unsupported"),
"formatted must contain 'unsupported' note; got: {}",
output.formatted
);
}
#[test]
fn test_exec_no_truncation_under_limits() {
let stdout = "hello world".to_string();
let stderr = "no errors".to_string();
let slot = 0u32;
let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
handle_output_persist(stdout, stderr, slot);
assert_eq!(out_stdout, "hello world");
assert_eq!(out_stderr, "no errors");
assert!(stdout_path.is_none());
assert!(stderr_path.is_none());
assert!(!byte_truncated);
}
#[test]
fn test_exec_byte_overflow_stdout_exceeds_30k() {
let stdout = "x".repeat(35_000);
let stderr = "small".to_string();
let slot = 0u32;
let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
handle_output_persist(stdout.clone(), stderr.clone(), slot);
assert!(byte_truncated, "byte_truncated should be true");
assert!(stdout_path.is_some(), "stdout_path should be set");
assert!(stderr_path.is_some(), "stderr_path should be set");
assert!(
out_stdout.len() <= 30_000,
"stdout should be truncated to <= 30k"
);
assert_eq!(out_stderr, "small", "stderr should be unchanged");
let base = std::env::temp_dir()
.join("aptu-coder-overflow")
.join(format!("slot-{slot}"));
let stdout_file = base.join("stdout");
assert!(
stdout_file.exists(),
"stdout slot file should exist after byte overflow"
);
}
#[test]
fn test_exec_byte_overflow_stderr_exceeds_10k() {
let stdout = "small".to_string();
let stderr = "y".repeat(15_000);
let slot = 1u32;
let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
handle_output_persist(stdout.clone(), stderr.clone(), slot);
assert!(byte_truncated, "byte_truncated should be true");
assert!(stdout_path.is_some(), "stdout_path should be set");
assert!(stderr_path.is_some(), "stderr_path should be set");
assert_eq!(out_stdout, "small", "stdout should be unchanged");
assert!(
out_stderr.len() <= 10_000,
"stderr should be truncated to <= 10k"
);
let base = std::env::temp_dir()
.join("aptu-coder-overflow")
.join(format!("slot-{slot}"));
let stderr_file = base.join("stderr");
assert!(
stderr_file.exists(),
"stderr slot file should exist after byte overflow"
);
}
#[test]
fn test_exec_byte_overflow_combined_exceeds_50k() {
let large_output = "z".repeat(60_000);
assert!(large_output.len() > SIZE_LIMIT);
let mut combined_truncated = false;
let truncated = if large_output.len() > SIZE_LIMIT {
combined_truncated = true;
let tail_start = large_output.len().saturating_sub(SIZE_LIMIT);
let safe_start = large_output[..tail_start].floor_char_boundary(tail_start);
large_output[safe_start..].to_string()
} else {
large_output.clone()
};
assert!(combined_truncated, "combined_truncated should be true");
assert!(
truncated.len() <= SIZE_LIMIT,
"output should be truncated to <= 50k"
);
}
#[test]
fn test_exec_line_and_byte_interaction() {
let lines: Vec<String> = (0..1500)
.map(|i| {
format!(
"line {} with some padding to make it longer: {}",
i,
"x".repeat(15)
)
})
.collect();
let stdout = lines.join("\n");
assert!(stdout.lines().count() <= 2000, "should have <= 2000 lines");
assert!(stdout.len() > 30_000, "should exceed 30k bytes");
let stderr = "".to_string();
let slot = 2u32;
let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
handle_output_persist(stdout.clone(), stderr, slot);
assert!(byte_truncated, "byte_truncated should be true");
assert!(stdout_path.is_some(), "stdout_path should be set");
assert!(
out_stdout.len() <= 30_000,
"stdout should be truncated by byte cap"
);
}
#[test]
fn test_exec_utf8_boundary_safety() {
let mut stdout = String::new();
for _ in 0..4000 {
stdout.push_str("hello world ");
}
stdout.push_str("こんにちは"); assert!(stdout.len() > 30_000, "stdout should exceed 30k bytes");
let stderr = "".to_string();
let slot = 5u32;
let (out_stdout, _out_stderr, _stdout_path, _stderr_path, byte_truncated) =
handle_output_persist(stdout, stderr, slot);
assert!(byte_truncated, "byte_truncated should be true");
assert!(
out_stdout.is_char_boundary(0),
"start should be char boundary"
);
assert!(
out_stdout.is_char_boundary(out_stdout.len()),
"end should be char boundary"
);
let _char_count = out_stdout.chars().count();
}
#[test]
fn test_filter_strip_lines_matching() {
let rule = types::FilterRule {
match_command: "^git\\s+pull".to_string(),
description: Some("test filter".to_string()),
strip_ansi: false,
strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+-]+".to_string()],
keep_lines_matching: vec![],
max_lines: None,
on_empty: None,
};
let strip_patterns = vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+-]+").unwrap()];
let compiled = CompiledRule {
pattern: Regex::new("^git\\s+pull").unwrap(),
strip_patterns,
keep_patterns: vec![],
rule,
};
let stdout = "Updating abc123..def456\n | 5 ++++\n | 3 ---\nFast-forward\n";
let filtered = apply_filter(&compiled, stdout);
assert!(!filtered.contains("| 5 ++++"), "should strip stat lines");
assert!(!filtered.contains("| 3 ---"), "should strip stat lines");
assert!(
filtered.contains("Updating"),
"should keep non-matching lines"
);
assert!(
filtered.contains("Fast-forward"),
"should keep non-matching lines"
);
}
#[test]
fn test_filter_on_empty_substitution() {
let rule = types::FilterRule {
match_command: "^git\\s+fetch".to_string(),
description: Some("test fetch".to_string()),
strip_ansi: false,
strip_lines_matching: vec!["^From ".to_string(), "^\\s+[a-f0-9]+\\.\\.".to_string()],
keep_lines_matching: vec![],
max_lines: None,
on_empty: Some("ok fetched".to_string()),
};
let strip_patterns = vec![
Regex::new("^From ").unwrap(),
Regex::new("^\\s+[a-f0-9]+\\.\\.").unwrap(),
];
let compiled = CompiledRule {
pattern: Regex::new("^git\\s+fetch").unwrap(),
strip_patterns,
keep_patterns: vec![],
rule,
};
let stdout = "From github.com:user/repo\n abc123..def456 main -> origin/main\n";
let filtered = apply_filter(&compiled, stdout);
assert_eq!(
filtered, "ok fetched",
"should return on_empty when all lines stripped"
);
}
#[test]
fn test_filter_passthrough_on_failure() {
let rule = types::FilterRule {
match_command: "^cargo\\s+build".to_string(),
description: Some("cargo build filter".to_string()),
strip_ansi: false,
strip_lines_matching: vec!["^\\s*Compiling ".to_string()],
keep_lines_matching: vec![],
max_lines: None,
on_empty: None,
};
let strip_patterns = vec![Regex::new("^\\s*Compiling ").unwrap()];
let compiled = CompiledRule {
pattern: Regex::new("^cargo\\s+build").unwrap(),
strip_patterns,
keep_patterns: vec![],
rule,
};
let stdout = " Compiling mylib v0.1.0\nerror: failed to compile\n";
let mut output = ShellOutput::new(
stdout.to_string(),
"".to_string(),
"".to_string(),
Some(1), false,
);
if output.exit_code == Some(0) {
output.stdout = apply_filter(&compiled, &output.stdout);
output.filter_applied = compiled
.rule
.description
.clone()
.or_else(|| Some(compiled.rule.match_command.clone()));
}
assert!(
output.filter_applied.is_none(),
"filter_applied should be None when exit_code != Some(0)"
);
assert!(
output.stdout.contains("Compiling"),
"stdout should be unchanged when exit_code != Some(0)"
);
let mut output2 = ShellOutput::new(
stdout.to_string(),
"".to_string(),
"".to_string(),
Some(0), false,
);
if output2.exit_code == Some(0) {
output2.stdout = apply_filter(&compiled, &output2.stdout);
output2.filter_applied = compiled
.rule
.description
.clone()
.or_else(|| Some(compiled.rule.match_command.clone()));
}
assert!(
output2.filter_applied.is_some(),
"filter_applied should be set when exit_code == Some(0)"
);
assert_eq!(
output2.filter_applied.as_ref().unwrap(),
"cargo build filter"
);
assert!(
!output2.stdout.contains("Compiling"),
"stdout should be filtered when exit_code == Some(0)"
);
}
#[test]
fn test_no_stat_injection() {
let command = "git pull origin main";
let result = maybe_inject_no_stat(command);
assert_eq!(
result, "git pull origin main --no-stat",
"should inject --no-stat"
);
}
#[test]
fn test_no_stat_not_injected_when_present() {
let command = "git pull --stat origin main";
let result = maybe_inject_no_stat(command);
assert_eq!(result, command, "should not inject when --stat present");
let command2 = "git pull --no-stat origin main";
let result2 = maybe_inject_no_stat(command2);
assert_eq!(
result2, command2,
"should not inject when --no-stat present"
);
let command3 = "git pull --verbose origin main";
let result3 = maybe_inject_no_stat(command3);
assert_eq!(
result3, command3,
"should not inject when --verbose present"
);
}
#[test]
fn test_no_stat_word_boundary_cases() {
let cases: &[(&str, &str)] = &[
("gitpull some-arg", "gitpull some-arg"),
("git log upstream/pull/123", "git log upstream/pull/123"),
(
"git pull origin main --rebase",
"git pull origin main --rebase --no-stat",
),
("git pull --no-stat", "git pull --no-stat"),
("git log --stat", "git log --stat"),
];
for (input, expected) in cases {
assert_eq!(maybe_inject_no_stat(input), *expected, "input: {input}");
}
}
#[test]
fn test_filter_applied_field_present() {
let rule = types::FilterRule {
match_command: "^git\\s+status".to_string(),
description: Some("git status filter".to_string()),
strip_ansi: false,
strip_lines_matching: vec!["^On branch".to_string()],
keep_lines_matching: vec![],
max_lines: Some(20),
on_empty: None,
};
let strip_patterns = vec![Regex::new("^On branch").unwrap()];
let compiled = CompiledRule {
pattern: Regex::new("^git\\s+status").unwrap(),
strip_patterns,
keep_patterns: vec![],
rule,
};
let stdout = "On branch main\nnothing to commit\n";
let filtered = apply_filter(&compiled, stdout);
assert!(
!filtered.contains("On branch"),
"apply_filter should strip matching lines"
);
assert!(
filtered.contains("nothing to commit"),
"apply_filter should keep non-matching lines"
);
let mut output = ShellOutput::new(filtered, "".to_string(), "".to_string(), Some(0), false);
output.filter_applied = compiled
.rule
.description
.clone()
.or_else(|| Some(compiled.rule.match_command.clone()));
assert!(
output.filter_applied.is_some(),
"filter_applied should be set when filter matches"
);
assert_eq!(output.filter_applied.as_ref().unwrap(), "git status filter");
}
#[test]
fn test_filter_keep_lines_matching() {
let rule = types::FilterRule {
match_command: "^cargo\\s+test".to_string(),
description: Some("test keep filter".to_string()),
strip_ansi: false,
strip_lines_matching: vec![],
keep_lines_matching: vec!["^test ".to_string(), "^FAILED".to_string()],
max_lines: None,
on_empty: None,
};
let compiled = filters::CompiledRule {
pattern: Regex::new("^cargo\\s+test").unwrap(),
strip_patterns: vec![],
keep_patterns: vec![
Regex::new("^test ").unwrap(),
Regex::new("^FAILED").unwrap(),
],
rule,
};
let stdout = " Compiling mylib v0.1.0\ntest foo::bar ... ok\ntest foo::baz ... FAILED\ntest result: FAILED\n";
let filtered = filters::apply_filter(&compiled, stdout);
assert!(filtered.contains("test foo::bar"), "should keep test lines");
assert!(
filtered.contains("test foo::baz"),
"should keep FAILED test lines"
);
assert!(!filtered.contains("Compiling"), "should drop compile lines");
}
#[test]
fn test_filter_max_lines_cap() {
let rule = types::FilterRule {
match_command: "^git\\s+log".to_string(),
description: Some("test max lines".to_string()),
strip_ansi: false,
strip_lines_matching: vec![],
keep_lines_matching: vec![],
max_lines: Some(3),
on_empty: None,
};
let compiled = filters::CompiledRule {
pattern: Regex::new("^git\\s+log").unwrap(),
strip_patterns: vec![],
keep_patterns: vec![],
rule,
};
let stdout = "line1\nline2\nline3\nline4\nline5\n";
let filtered = filters::apply_filter(&compiled, stdout);
assert_eq!(filtered.lines().count(), 3, "should cap at 3 lines");
assert!(filtered.contains("line1"));
assert!(filtered.contains("line3"));
assert!(
!filtered.contains("line4"),
"should not include lines beyond max"
);
}
#[test]
fn test_filter_git_show_strips_patch_hunks() {
let compiled = filters::CompiledRule {
pattern: Regex::new("^git\\s+show").unwrap(),
strip_patterns: vec![
Regex::new("^@@").unwrap(),
Regex::new("^[+-][^+-]").unwrap(),
],
keep_patterns: vec![],
rule: types::FilterRule {
match_command: "^git\\s+show".to_string(),
description: None,
strip_ansi: true,
strip_lines_matching: vec!["^@@".to_string(), "^[+-][^+-]".to_string()],
keep_lines_matching: vec![],
max_lines: Some(200),
on_empty: None,
},
};
let stdout = "commit abc123\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,3 +1,4 @@\n-old line\n+new line\n context line\n";
let filtered = filters::apply_filter(&compiled, stdout);
assert!(
filtered.contains("--- a/src/lib.rs"),
"should keep --- file header"
);
assert!(
filtered.contains("+++ b/src/lib.rs"),
"should keep +++ file header"
);
assert!(!filtered.contains("@@ -1,3"), "should strip hunk headers");
assert!(
!filtered.contains("-old line"),
"should strip removed lines"
);
assert!(!filtered.contains("+new line"), "should strip added lines");
}
#[test]
fn test_filter_on_empty_from_empty_input() {
let compiled = filters::CompiledRule {
pattern: Regex::new("^git\\s+diff").unwrap(),
strip_patterns: vec![],
keep_patterns: vec![],
rule: types::FilterRule {
match_command: "^git\\s+diff".to_string(),
description: None,
strip_ansi: true,
strip_lines_matching: vec![],
keep_lines_matching: vec![],
max_lines: Some(100),
on_empty: Some("ok (working tree clean)".to_string()),
},
};
assert_eq!(
filters::apply_filter(&compiled, ""),
"ok (working tree clean)",
"on_empty should fire on empty input"
);
}
#[test]
fn test_filter_applied_to_interleaved_with_both_streams() {
let compiled = filters::CompiledRule {
pattern: Regex::new("^git\\s+pull").unwrap(),
strip_patterns: vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+\\-]+").unwrap()],
keep_patterns: vec![],
rule: types::FilterRule {
match_command: "^git\\s+pull".to_string(),
description: None,
strip_ansi: false,
strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+\\-]+".to_string()],
keep_lines_matching: vec![],
max_lines: None,
on_empty: None,
},
};
let interleaved = " | 42 ++++++++++++\nFrom https://github.com/example/repo\n";
let result = filters::apply_filter(&compiled, interleaved);
assert!(
!result.contains("| 42"),
"strip-matched line should be absent from filtered interleaved"
);
assert!(
result.contains("From https://github.com/example/repo"),
"stderr-origin line should be preserved in filtered interleaved"
);
}
#[test]
fn test_on_empty_substitution_in_interleaved() {
let compiled = filters::CompiledRule {
pattern: Regex::new("^git\\s+pull").unwrap(),
strip_patterns: vec![Regex::new(".*").unwrap()],
keep_patterns: vec![],
rule: types::FilterRule {
match_command: "^git\\s+pull".to_string(),
description: None,
strip_ansi: false,
strip_lines_matching: vec![".*".to_string()],
keep_lines_matching: vec![],
max_lines: None,
on_empty: Some("ok (up-to-date)".to_string()),
},
};
let interleaved = "Already up to date.\nFrom https://github.com/example/repo\n";
let result = filters::apply_filter(&compiled, interleaved);
assert_eq!(
result, "ok (up-to-date)",
"on_empty should be returned when filter strips all lines in interleaved"
);
}
#[test]
fn test_line_cap_fires_before_byte_cap() {
let line = "abcde";
let stdout: String = std::iter::repeat(format!("{}\n", line))
.take(2500)
.collect();
assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
assert!(stdout.len() < 30_000, "should be under byte cap");
let stderr = String::new();
let slot = 42u32;
let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
handle_output_persist(stdout, stderr, slot);
assert!(
!byte_truncated,
"byte cap should NOT fire (under 30k bytes)"
);
assert!(
stdout_path.is_some(),
"stdout_path should be set when line cap fires"
);
let line_count = out_stdout.lines().count();
assert!(
line_count <= 50,
"returned content should have at most 50 lines, got {}",
line_count
);
assert!(line_count > 0, "returned content should not be empty");
}
#[test]
fn test_project_local_overrides_builtin() {
use std::io::Write;
let tmp = std::env::temp_dir().join(format!(
"aptu-test-project-local-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let aptu_dir = tmp.join(".aptu");
std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
let mut f =
std::fs::File::create(aptu_dir.join("filters.toml")).expect("should create filters.toml");
f.write_all(toml_content.as_bytes())
.expect("should write toml");
drop(f);
let rules = filters::load_filter_table(&tmp);
let first_rule = rules.first().expect("should have at least one rule");
assert!(
first_rule.pattern.is_match("my-custom-tool --flag"),
"project-local rule should be first (index 0)"
);
assert_eq!(
first_rule.rule.on_empty.as_deref(),
Some("project-local-only-marker"),
"project-local rule on_empty should match what was written"
);
let has_git_pull = rules
.iter()
.any(|r| r.pattern.is_match("git pull origin main"));
assert!(
has_git_pull,
"built-in git pull rule should still be present"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_invalid_toml_falls_back_gracefully() {
use std::io::Write;
let tmp = std::env::temp_dir().join(format!(
"aptu-test-invalid-toml-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let aptu_dir = tmp.join(".aptu");
std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
let mut f =
std::fs::File::create(aptu_dir.join("filters.toml")).expect("should create filters.toml");
f.write_all(b"schema_version = INVALID_VALUE {{{{")
.expect("should write garbage");
drop(f);
let rules = filters::load_filter_table(&tmp);
let has_git_pull = rules
.iter()
.any(|r| r.pattern.is_match("git pull origin main"));
assert!(
has_git_pull,
"should have git pull built-in rule after invalid TOML"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_invalid_schema_version_falls_back_gracefully() {
use std::io::Write;
let tmp = std::env::temp_dir().join(format!(
"aptu-test-schema-version-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let aptu_dir = tmp.join(".aptu");
std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
let toml_content = "schema_version = 2\n[[filters]]\nmatch_command = \"^my-v2-tool\"\nkeep_lines_matching = []\n";
let mut f =
std::fs::File::create(aptu_dir.join("filters.toml")).expect("should create filters.toml");
f.write_all(toml_content.as_bytes())
.expect("should write toml");
drop(f);
let rules = filters::load_filter_table(&tmp);
let has_git_pull = rules
.iter()
.any(|r| r.pattern.is_match("git pull origin main"));
assert!(
has_git_pull,
"should have git pull built-in rule after schema_version=2 rejection"
);
let has_v2_rule = rules
.iter()
.any(|r| r.pattern.is_match("my-v2-tool --flag"));
assert!(
!has_v2_rule,
"schema_version=2 rule should not be loaded; only built-ins expected"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_metric_chars_threshold_breach_fires() {
let output_chars: usize = 35_000;
let event = crate::metrics::MetricEvent {
ts: 0,
tool: "exec_command",
duration_ms: 1,
output_chars,
param_path_depth: 0,
max_depth: None,
result: "ok",
error_type: None,
error_subtype: None,
session_id: None,
seq: None,
cache_hit: None,
cache_write_failure: None,
cache_tier: None,
exit_code: None,
timed_out: false,
output_truncated: None,
chars_threshold_breach: output_chars > 30_000,
file_ext: None,
filter_applied: None,
language: None,
};
assert!(
event.chars_threshold_breach,
"chars_threshold_breach should be true for output_chars=35000"
);
}
#[test]
fn test_metric_chars_threshold_breach_no_fire() {
let output_chars: usize = 5_000;
let event = crate::metrics::MetricEvent {
ts: 0,
tool: "exec_command",
duration_ms: 1,
output_chars,
param_path_depth: 0,
max_depth: None,
result: "ok",
error_type: None,
error_subtype: None,
session_id: None,
seq: None,
cache_hit: None,
cache_write_failure: None,
cache_tier: None,
exit_code: None,
timed_out: false,
output_truncated: None,
chars_threshold_breach: output_chars > 30_000,
file_ext: None,
filter_applied: None,
language: None,
};
assert!(
!event.chars_threshold_breach,
"chars_threshold_breach should be false for output_chars=5000"
);
}
#[test]
fn test_strip_cd_prefix_basic() {
let (cmd, path) = strip_cd_prefix("cd /tmp && echo hello");
assert_eq!(cmd, "echo hello");
assert_eq!(path, Some("/tmp"));
}
#[test]
fn test_strip_cd_prefix_no_ampersand() {
let (cmd, path) = strip_cd_prefix("cd /tmp");
assert_eq!(cmd, "cd /tmp");
assert_eq!(path, None);
}
#[test]
fn test_strip_cd_prefix_with_extra_spaces() {
let (cmd, path) = strip_cd_prefix("cd /tmp && echo hello");
assert_eq!(path, Some("/tmp"));
assert_eq!(cmd, "echo hello");
}
#[test]
fn test_strip_cd_prefix_splits_on_first_ampersand_only() {
let (cmd, path) = strip_cd_prefix("cd /a && cmd1 && cd /b && cmd2");
assert_eq!(path, Some("/a"));
assert_eq!(cmd, "cmd1 && cd /b && cmd2");
}