use assert_cmd::assert::OutputAssertExt;
use aws_config::BehaviorVersion;
use aws_sdk_s3::{Client, primitives::ByteStream};
use predicates::prelude::*;
use std::{process::Command, time::Duration};
use testcontainers::{
ContainerAsync, GenericImage, ImageExt,
core::{ContainerPort, WaitFor},
runners::AsyncRunner,
};
use tokio::sync::OnceCell;
static LOCALSTACK: OnceCell<SharedLocalStack> = OnceCell::const_new();
struct SharedLocalStack {
_container: Option<ContainerAsync<GenericImage>>,
endpoint: String,
}
const LOCALSTACK_IMAGE: &str = "localstack/localstack";
const LOCALSTACK_TAG: &str = "4.12";
const LOCALSTACK_PORT: u16 = 4566;
async fn is_localstack_running() -> Option<String> {
let endpoint = format!("http://localhost:{}", LOCALSTACK_PORT);
match tokio::time::timeout(
Duration::from_secs(1),
tokio::net::TcpStream::connect(format!("localhost:{}", LOCALSTACK_PORT)),
)
.await
{
Ok(Ok(_)) => Some(endpoint),
_ => None,
}
}
async fn wait_for_localstack_ready(endpoint: &str, max_wait: Duration) -> Result<(), String> {
let start = std::time::Instant::now();
let addr = {
let without_scheme = endpoint
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(endpoint);
without_scheme
.split('/')
.next()
.unwrap_or(without_scheme)
.to_string()
};
while start.elapsed() < max_wait {
match tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(&addr),
)
.await
{
Ok(Ok(_)) => {
eprintln!("LocalStack health check passed");
return Ok(());
}
_ => {
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
Err(format!(
"LocalStack did not become ready within {:?}",
max_wait
))
}
async fn get_localstack() -> &'static SharedLocalStack {
LOCALSTACK
.get_or_init(|| async {
if let Some(endpoint) = is_localstack_running().await {
eprintln!("Reusing existing LocalStack at {}", endpoint);
return SharedLocalStack {
_container: None,
endpoint,
};
}
eprintln!("Starting new LocalStack container...");
let container = GenericImage::new(LOCALSTACK_IMAGE, LOCALSTACK_TAG)
.with_exposed_port(ContainerPort::Tcp(LOCALSTACK_PORT))
.with_wait_for(WaitFor::message_on_stdout("Ready."))
.with_env_var("SERVICES", "s3")
.start()
.await
.expect("Failed to start shared LocalStack container");
let host_port = container
.get_host_port_ipv4(LOCALSTACK_PORT)
.await
.expect("Failed to get container port");
let endpoint = format!("http://localhost:{}", host_port);
wait_for_localstack_ready(&endpoint, Duration::from_secs(120))
.await
.expect("LocalStack failed to become ready");
eprintln!("LocalStack ready at {}", endpoint);
SharedLocalStack {
_container: Some(container),
endpoint,
}
})
.await
}
struct LocalStackFixture {
endpoint: String,
client: Client,
bucket: String,
}
fn unique_bucket_name(base_name: &str) -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
format!("{}-{}", base_name, timestamp)
}
impl LocalStackFixture {
async fn new(bucket_name: &str) -> Self {
let localstack = get_localstack().await;
let endpoint = localstack.endpoint.clone();
let config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(&endpoint)
.region("us-east-1")
.credentials_provider(aws_sdk_s3::config::Credentials::new(
"test",
"test",
None,
None,
"localstack",
))
.load()
.await;
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.force_path_style(true)
.build();
let client = Client::from_conf(s3_config);
let mut retries = 3;
loop {
match client.create_bucket().bucket(bucket_name).send().await {
Ok(_) => {
eprintln!("Created bucket: {}", bucket_name);
break;
}
Err(e) => {
let error_str = e.to_string();
if error_str.contains("BucketAlreadyOwnedByYou")
|| error_str.contains("BucketAlreadyExists")
{
eprintln!("Bucket already exists, reusing: {}", bucket_name);
break;
}
if retries > 0 {
eprintln!(
"Bucket creation failed, retrying... ({} attempts left): {}",
retries, e
);
retries -= 1;
tokio::time::sleep(Duration::from_secs(1)).await;
} else {
panic!("Failed to create bucket after retries: {}", e);
}
}
}
}
Self {
endpoint,
client,
bucket: bucket_name.to_string(),
}
}
async fn put_object(&self, key: &str, body: &[u8]) {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(body.to_vec()))
.send()
.await
.expect("Failed to put object");
}
async fn put_object_with_storage_class(
&self,
key: &str,
body: &[u8],
storage_class: aws_sdk_s3::types::StorageClass,
) {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(body.to_vec()))
.storage_class(storage_class)
.send()
.await
.expect("Failed to put object");
}
async fn put_object_with_tags(&self, key: &str, body: &[u8], tags: &[(&str, &str)]) {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(body.to_vec()))
.send()
.await
.expect("Failed to put object");
if !tags.is_empty() {
let tag_set: Vec<aws_sdk_s3::types::Tag> = tags
.iter()
.map(|(k, v)| {
aws_sdk_s3::types::Tag::builder()
.key(*k)
.value(*v)
.build()
.unwrap()
})
.collect();
self.client
.put_object_tagging()
.bucket(&self.bucket)
.key(key)
.tagging(
aws_sdk_s3::types::Tagging::builder()
.set_tag_set(Some(tag_set))
.build()
.unwrap(),
)
.send()
.await
.expect("Failed to set object tags");
}
}
fn s3_path(&self, prefix: &str) -> String {
format!("s3://{}/{}", self.bucket, prefix)
}
async fn enable_versioning(&self) {
self.client
.put_bucket_versioning()
.bucket(&self.bucket)
.versioning_configuration(
aws_sdk_s3::types::VersioningConfiguration::builder()
.status(aws_sdk_s3::types::BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await
.expect("Failed to enable versioning");
}
async fn delete_object(&self, key: &str) {
self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.expect("Failed to delete object");
}
fn s3find_command(&self) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_s3find"));
cmd.env("AWS_ACCESS_KEY_ID", "test")
.env("AWS_SECRET_ACCESS_KEY", "test")
.env("AWS_DEFAULT_REGION", "us-east-1")
.arg("--endpoint-url")
.arg(&self.endpoint)
.arg("--force-path-style");
cmd
}
}
#[tokio::test]
async fn test_ls_basic() {
let bucket_name = unique_bucket_name("test-ls-basic");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("file1.txt", b"content1").await;
fixture.put_object("file2.txt", b"content2").await;
fixture.put_object("dir/file3.txt", b"content3").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("")).arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("file1.txt"))
.stdout(predicate::str::contains("file2.txt"))
.stdout(predicate::str::contains("dir/file3.txt"));
}
#[tokio::test]
async fn test_ls_with_name_filter() {
let bucket_name = unique_bucket_name("test-ls-name-filter");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("document.txt", b"content").await;
fixture.put_object("image.png", b"image").await;
fixture.put_object("archive.txt", b"archive").await;
fixture.put_object("data.json", b"{}").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--name")
.arg("*.txt")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("document.txt"))
.stdout(predicate::str::contains("archive.txt"))
.stdout(predicate::str::contains("image.png").not())
.stdout(predicate::str::contains("data.json").not());
}
#[tokio::test]
async fn test_ls_with_iname_filter() {
let bucket_name = unique_bucket_name("test-ls-iname-filter");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("Document.TXT", b"content").await;
fixture.put_object("IMAGE.PNG", b"image").await;
fixture.put_object("readme.txt", b"readme").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--iname")
.arg("*.txt")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("Document.TXT"))
.stdout(predicate::str::contains("readme.txt"))
.stdout(predicate::str::contains("IMAGE.PNG").not());
}
#[tokio::test]
async fn test_ls_with_regex_filter() {
let bucket_name = unique_bucket_name("test-ls-regex");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("file001.txt", b"content").await;
fixture.put_object("file002.txt", b"content").await;
fixture.put_object("document.txt", b"content").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--regex")
.arg(r"file\d+\.txt")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("file001.txt"))
.stdout(predicate::str::contains("file002.txt"))
.stdout(predicate::str::contains("document.txt").not());
}
#[tokio::test]
async fn test_print_command() {
let bucket_name = unique_bucket_name("test-print");
let fixture = LocalStackFixture::new(&bucket_name).await;
let test_content = b"Hello from LocalStack!";
fixture.put_object("test.txt", test_content).await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("test.txt")).arg("print");
let expected_path = format!("s3://{}/test.txt", bucket_name);
cmd.assert()
.success()
.stdout(predicate::str::contains(&expected_path))
.stdout(predicate::str::contains("STANDARD"));
}
#[tokio::test]
async fn test_size_filter() {
let bucket_name = unique_bucket_name("test-size-filter");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("small.txt", b"small").await; fixture
.put_object("medium.txt", b"medium file content")
.await; fixture.put_object("large.txt", &[b'x'; 1000]).await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--bytes-size")
.arg("+100")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("large.txt"))
.stdout(predicate::str::contains("small.txt").not())
.stdout(predicate::str::contains("medium.txt").not());
}
#[tokio::test]
async fn test_storage_class_filter() {
let bucket_name = unique_bucket_name("test-storage-class");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_storage_class(
"standard.txt",
b"standard",
aws_sdk_s3::types::StorageClass::Standard,
)
.await;
fixture
.put_object_with_storage_class(
"glacier.txt",
b"glacier",
aws_sdk_s3::types::StorageClass::Glacier,
)
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--storage-class")
.arg("GLACIER")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("glacier.txt"))
.stdout(predicate::str::contains("standard.txt").not());
}
#[tokio::test]
async fn test_ls_with_prefix() {
let bucket_name = unique_bucket_name("test-ls-prefix");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture.put_object("docs/readme.txt", b"readme").await;
fixture.put_object("docs/guide.txt", b"guide").await;
fixture.put_object("images/logo.png", b"logo").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("docs/")).arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("readme.txt"))
.stdout(predicate::str::contains("guide.txt"))
.stdout(predicate::str::contains("root.txt").not())
.stdout(predicate::str::contains("logo.png").not());
}
#[tokio::test]
async fn test_combined_filters() {
let bucket_name = unique_bucket_name("test-combined-filters");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("data001.txt", &[b'x'; 200]).await;
fixture.put_object("data002.txt", &[b'x'; 50]).await;
fixture.put_object("info001.txt", &[b'x'; 300]).await;
fixture.put_object("readme.md", &[b'x'; 250]).await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--name")
.arg("data*.txt")
.arg("--bytes-size")
.arg("+100")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("data001.txt"))
.stdout(predicate::str::contains("data002.txt").not())
.stdout(predicate::str::contains("info001.txt").not())
.stdout(predicate::str::contains("readme.md").not());
}
#[tokio::test]
async fn test_empty_bucket() {
let bucket_name = unique_bucket_name("test-empty-bucket");
let fixture = LocalStackFixture::new(&bucket_name).await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("")).arg("ls");
cmd.assert().success();
}
#[tokio::test]
async fn test_nonexistent_prefix() {
let bucket_name = unique_bucket_name("test-nonexistent");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("exists.txt", b"content").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("nonexistent/")).arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("exists.txt").not());
}
#[tokio::test]
async fn test_maxdepth_zero() {
let bucket_name = unique_bucket_name("test-maxdepth-zero");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture.put_object("dir1/file.txt", b"level1").await;
fixture.put_object("dir2/file.txt", b"level1").await;
fixture.put_object("dir1/subdir/deep.txt", b"level2").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--maxdepth")
.arg("0")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("root.txt"))
.stdout(predicate::str::contains("dir1/file.txt").not())
.stdout(predicate::str::contains("dir2/file.txt").not())
.stdout(predicate::str::contains("dir1/subdir/deep.txt").not());
}
#[tokio::test]
async fn test_maxdepth_one() {
let bucket_name = unique_bucket_name("test-maxdepth-one");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture.put_object("dir1/file.txt", b"level1").await;
fixture.put_object("dir2/file.txt", b"level1").await;
fixture.put_object("dir1/subdir/deep.txt", b"level2").await;
fixture
.put_object("dir2/subdir/another.txt", b"level2")
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--maxdepth")
.arg("1")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("root.txt"))
.stdout(predicate::str::contains("dir1/file.txt"))
.stdout(predicate::str::contains("dir2/file.txt"))
.stdout(predicate::str::contains("dir1/subdir/deep.txt").not())
.stdout(predicate::str::contains("dir2/subdir/another.txt").not());
}
#[tokio::test]
async fn test_maxdepth_two() {
let bucket_name = unique_bucket_name("test-maxdepth-two");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture.put_object("dir1/file.txt", b"level1").await;
fixture.put_object("dir1/subdir/deep.txt", b"level2").await;
fixture
.put_object("dir1/subdir/deeper/verydeep.txt", b"level3")
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--maxdepth")
.arg("2")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("root.txt"))
.stdout(predicate::str::contains("dir1/file.txt"))
.stdout(predicate::str::contains("dir1/subdir/deep.txt"))
.stdout(predicate::str::contains("dir1/subdir/deeper/verydeep.txt").not());
}
#[tokio::test]
async fn test_maxdepth_with_prefix() {
let bucket_name = unique_bucket_name("test-maxdepth-prefix");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("data/file.txt", b"data").await;
fixture
.put_object("data/subdir/nested.txt", b"nested")
.await;
fixture
.put_object("data/subdir/deep/verydeep.txt", b"deep")
.await;
fixture.put_object("other/file.txt", b"other").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("data/"))
.arg("--maxdepth")
.arg("2")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("data/file.txt"))
.stdout(predicate::str::contains("data/subdir/nested.txt"))
.stdout(predicate::str::contains("data/subdir/deep/verydeep.txt").not())
.stdout(predicate::str::contains("other/file.txt").not());
}
#[tokio::test]
async fn test_maxdepth_with_name_filter() {
let bucket_name = unique_bucket_name("test-maxdepth-filter");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture.put_object("root.log", b"log").await;
fixture.put_object("dir1/file.txt", b"level1").await;
fixture.put_object("dir1/file.log", b"level1").await;
fixture.put_object("dir1/subdir/deep.txt", b"level2").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--maxdepth")
.arg("1")
.arg("--name")
.arg("*.txt")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("root.txt"))
.stdout(predicate::str::contains("dir1/file.txt"))
.stdout(predicate::str::contains("root.log").not())
.stdout(predicate::str::contains("dir1/file.log").not())
.stdout(predicate::str::contains("dir1/subdir/deep.txt").not());
}
#[tokio::test]
async fn test_maxdepth_empty_subdirectories() {
let bucket_name = unique_bucket_name("test-maxdepth-empty");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.put_object("root.txt", b"root").await;
fixture
.put_object("empty_at_level1/subdir/file.txt", b"deep")
.await;
fixture.put_object("has_files/file.txt", b"level1").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--maxdepth")
.arg("1")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("root.txt"))
.stdout(predicate::str::contains("has_files/file.txt"))
.stdout(predicate::str::contains("empty_at_level1/subdir/file.txt").not());
}
#[tokio::test]
async fn test_all_versions_basic() {
let bucket_name = unique_bucket_name("test-versions-basic");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.enable_versioning().await;
fixture.put_object("file.txt", b"version 1").await;
fixture.put_object("file.txt", b"version 2").await;
fixture.put_object("file.txt", b"version 3").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("")).arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("file.txt"));
let mut cmd_versions = fixture.s3find_command();
cmd_versions
.arg(fixture.s3_path(""))
.arg("--all-versions")
.arg("ls");
let output = cmd_versions.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
let count = stdout.matches("file.txt").count();
assert_eq!(
3, count,
"Expected exactly 3 versions, found {} occurrences of file.txt in output:\n{}",
count, stdout
);
let version_ids: Vec<&str> = stdout
.lines()
.filter_map(|line| {
if line.contains("file.txt") && line.contains("versionId=") {
line.split("versionId=")
.nth(1)
.and_then(|s| s.split_whitespace().next())
} else {
None
}
})
.collect();
assert_eq!(
3,
version_ids.len(),
"Expected 3 version IDs, found {}: {:?}",
version_ids.len(),
version_ids
);
let mut unique_ids = version_ids.clone();
unique_ids.sort();
unique_ids.dedup();
assert_eq!(
version_ids.len(),
unique_ids.len(),
"Version IDs should be distinct, found duplicates: {:?}",
version_ids
);
}
#[tokio::test]
async fn test_all_versions_with_delete_markers() {
let bucket_name = unique_bucket_name("test-versions-delete");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.enable_versioning().await;
fixture.put_object("deleted.txt", b"original").await;
fixture.delete_object("deleted.txt").await;
fixture.put_object("kept.txt", b"v1").await;
fixture.put_object("kept.txt", b"v2").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("")).arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("kept.txt"))
.stdout(predicate::str::contains("deleted.txt").not());
let mut cmd_versions = fixture.s3find_command();
cmd_versions
.arg(fixture.s3_path(""))
.arg("--all-versions")
.arg("ls");
cmd_versions
.assert()
.success()
.stdout(predicate::str::contains("kept.txt"))
.stdout(predicate::str::contains("deleted.txt"));
}
#[tokio::test]
async fn test_all_versions_with_name_filter() {
let bucket_name = unique_bucket_name("test-versions-filter");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.enable_versioning().await;
fixture.put_object("doc.txt", b"v1").await;
fixture.put_object("doc.txt", b"v2").await;
fixture.put_object("image.png", b"v1").await;
fixture.put_object("image.png", b"v2").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--all-versions")
.arg("--name")
.arg("*.txt")
.arg("ls");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("doc.txt"),
"Expected doc.txt in output:\n{}",
stdout
);
assert!(
!stdout.contains("image.png"),
"Expected no image.png in output:\n{}",
stdout
);
let count = stdout.matches("doc.txt").count();
assert!(
count >= 2,
"Expected at least 2 versions of doc.txt, found {}",
count
);
}
#[tokio::test]
async fn test_all_versions_with_maxdepth_warning() {
let bucket_name = unique_bucket_name("test-versions-maxdepth");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture.enable_versioning().await;
fixture.put_object("root.txt", b"v1").await;
fixture.put_object("root.txt", b"v2").await;
fixture.put_object("dir/nested.txt", b"v1").await;
fixture.put_object("dir/nested.txt", b"v2").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--all-versions")
.arg("--maxdepth")
.arg("0")
.arg("ls");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("root.txt"),
"Expected root.txt in output:\n{}",
stdout
);
assert!(
stdout.contains("dir/nested.txt"),
"Expected dir/nested.txt in output (maxdepth should be ignored):\n{}",
stdout
);
assert!(
stderr.contains("--maxdepth is ignored when --all-versions is used"),
"Expected warning about maxdepth being ignored in stderr:\n{}",
stderr
);
let root_count = stdout.matches("root.txt").count();
let nested_count = stdout.matches("dir/nested.txt").count();
assert!(
root_count >= 2 && nested_count >= 2,
"Expected at least 2 versions of each file, found root.txt={}, nested.txt={}",
root_count,
nested_count
);
}
#[tokio::test]
async fn test_tag_filter_basic() {
let bucket_name = unique_bucket_name("test-tag-basic");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags("prod-file.txt", b"prod content", &[("env", "production")])
.await;
fixture
.put_object_with_tags("dev-file.txt", b"dev content", &[("env", "development")])
.await;
fixture
.put_object_with_tags(
"staging-file.txt",
b"staging content",
&[("env", "staging")],
)
.await;
fixture.put_object("no-tags.txt", b"no tags").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("env=production")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("prod-file.txt"))
.stdout(predicate::str::contains("dev-file.txt").not())
.stdout(predicate::str::contains("staging-file.txt").not())
.stdout(predicate::str::contains("no-tags.txt").not());
}
#[tokio::test]
async fn test_tag_exists_filter() {
let bucket_name = unique_bucket_name("test-tag-exists");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags("owned.txt", b"owned", &[("owner", "team-a")])
.await;
fixture
.put_object_with_tags("also-owned.txt", b"also owned", &[("owner", "team-b")])
.await;
fixture
.put_object_with_tags("categorized.txt", b"categorized", &[("category", "data")])
.await;
fixture.put_object("untagged.txt", b"no tags").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag-exists")
.arg("owner")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("owned.txt"))
.stdout(predicate::str::contains("also-owned.txt"))
.stdout(predicate::str::contains("categorized.txt").not())
.stdout(predicate::str::contains("untagged.txt").not());
}
#[tokio::test]
async fn test_tag_filter_multiple_and_logic() {
let bucket_name = unique_bucket_name("test-tag-multiple");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags(
"both-match.txt",
b"content",
&[("env", "production"), ("team", "data")],
)
.await;
fixture
.put_object_with_tags(
"only-env.txt",
b"content",
&[("env", "production"), ("team", "other")],
)
.await;
fixture
.put_object_with_tags(
"only-team.txt",
b"content",
&[("env", "staging"), ("team", "data")],
)
.await;
fixture
.put_object_with_tags(
"neither.txt",
b"content",
&[("env", "dev"), ("team", "other")],
)
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("env=production")
.arg("--tag")
.arg("team=data")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("both-match.txt"))
.stdout(predicate::str::contains("only-env.txt").not())
.stdout(predicate::str::contains("only-team.txt").not())
.stdout(predicate::str::contains("neither.txt").not());
}
#[tokio::test]
async fn test_tag_filter_combined_with_name_filter() {
let bucket_name = unique_bucket_name("test-tag-combined");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags("report.txt", b"report", &[("type", "important")])
.await;
fixture
.put_object_with_tags("data.json", b"{}", &[("type", "important")])
.await;
fixture
.put_object_with_tags("notes.txt", b"notes", &[("type", "draft")])
.await;
fixture.put_object("readme.txt", b"readme").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--name")
.arg("*.txt")
.arg("--tag")
.arg("type=important")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("report.txt"))
.stdout(predicate::str::contains("data.json").not()) .stdout(predicate::str::contains("notes.txt").not()) .stdout(predicate::str::contains("readme.txt").not()); }
#[tokio::test]
async fn test_tag_filter_with_limit() {
let bucket_name = unique_bucket_name("test-tag-limit");
let fixture = LocalStackFixture::new(&bucket_name).await;
for i in 1..=5 {
fixture
.put_object_with_tags(&format!("file{}.txt", i), b"content", &[("batch", "test")])
.await;
}
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("batch=test")
.arg("--limit")
.arg("2")
.arg("ls");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
let count = stdout
.lines()
.filter(|line| line.contains("file") && line.contains(".txt"))
.count();
assert_eq!(
count, 2,
"Expected exactly 2 results with --limit 2, got {}: {}",
count, stdout
);
}
#[tokio::test]
async fn test_lstags_command() {
let bucket_name = unique_bucket_name("test-lstags");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags(
"multi-tag.txt",
b"content",
&[("env", "production"), ("owner", "team-a")],
)
.await;
fixture
.put_object_with_tags("single-tag.txt", b"content", &[("category", "data")])
.await;
fixture.put_object("no-tags.txt", b"content").await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path("")).arg("lstags");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("multi-tag.txt"),
"Expected multi-tag.txt in output: {}",
stdout
);
assert!(
stdout.contains("single-tag.txt"),
"Expected single-tag.txt in output: {}",
stdout
);
assert!(
stdout.contains("no-tags.txt"),
"Expected no-tags.txt in output: {}",
stdout
);
assert!(
stdout.contains("env=production") || stdout.contains("env:production"),
"Expected env=production tag in output: {}",
stdout
);
assert!(
stdout.contains("owner=team-a") || stdout.contains("owner:team-a"),
"Expected owner=team-a tag in output: {}",
stdout
);
}
#[tokio::test]
async fn test_tag_filter_with_summarize() {
let bucket_name = unique_bucket_name("test-tag-summarize");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags("file1.txt", b"content1", &[("env", "prod")])
.await;
fixture
.put_object_with_tags("file2.txt", b"content2", &[("env", "prod")])
.await;
fixture
.put_object_with_tags("file3.txt", b"content3", &[("env", "dev")])
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("env=prod")
.arg("--summarize")
.arg("ls");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("file1.txt"),
"Expected file1.txt in output: {}",
stdout
);
assert!(
stdout.contains("file2.txt"),
"Expected file2.txt in output: {}",
stdout
);
assert!(
stderr.contains("Tag fetch stats") || stderr.contains("success"),
"Expected tag fetch stats in stderr: {}",
stderr
);
}
#[tokio::test]
async fn test_tag_filter_no_matches() {
let bucket_name = unique_bucket_name("test-tag-no-match");
let fixture = LocalStackFixture::new(&bucket_name).await;
fixture
.put_object_with_tags("file1.txt", b"content", &[("env", "development")])
.await;
fixture
.put_object_with_tags("file2.txt", b"content", &[("env", "staging")])
.await;
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("env=nonexistent")
.arg("ls");
cmd.assert()
.success()
.stdout(predicate::str::contains("file1.txt").not())
.stdout(predicate::str::contains("file2.txt").not());
}
#[tokio::test]
async fn test_tag_concurrency_option() {
let bucket_name = unique_bucket_name("test-tag-concurrency");
let fixture = LocalStackFixture::new(&bucket_name).await;
for i in 1..=3 {
fixture
.put_object_with_tags(
&format!("file{}.txt", i),
b"content",
&[("batch", "concurrent")],
)
.await;
}
let mut cmd = fixture.s3find_command();
cmd.arg(fixture.s3_path(""))
.arg("--tag")
.arg("batch=concurrent")
.arg("--tag-concurrency")
.arg("10")
.arg("ls");
let output = cmd.output().expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("file1.txt"),
"Expected file1.txt: {}",
stdout
);
assert!(
stdout.contains("file2.txt"),
"Expected file2.txt: {}",
stdout
);
assert!(
stdout.contains("file3.txt"),
"Expected file3.txt: {}",
stdout
);
}