use anyhow::Result;
use clap::{CommandFactory, Parser};
use clap_complete::generate;
use tracing::{debug, error, trace};
use s3rm_rs::callback::user_defined_event_callback::UserDefinedEventCallback;
use s3rm_rs::callback::user_defined_filter_callback::UserDefinedFilterCallback;
use s3rm_rs::config::Config;
use s3rm_rs::types::event_callback::EventType;
use s3rm_rs::{
CLIArgs, DeletionPipeline, create_pipeline_cancellation_token, exit_code_from_error,
is_cancelled_error,
};
mod ctrl_c_handler;
pub mod indicator;
#[cfg(test)]
mod indicator_properties;
mod tracing_init;
pub mod ui_config;
const EXIT_CODE_WARNING: i32 = 3;
const EXIT_CODE_ABNORMAL_TERMINATION: i32 = 101;
#[tokio::main]
async fn main() -> Result<()> {
let config = load_config_exit_if_err();
if let Some(shell) = config.auto_complete_shell {
generate(
shell,
&mut CLIArgs::command(),
"s3rm",
&mut std::io::stdout(),
);
return Ok(());
}
start_tracing_if_necessary(&config);
trace!("config = {:?}", config);
run(config).await
}
fn load_config_exit_if_err() -> Config {
match Config::try_from(CLIArgs::parse()) {
Ok(config) => config,
Err(error_message) => {
clap::Error::raw(clap::error::ErrorKind::ValueValidation, error_message).exit();
}
}
}
fn start_tracing_if_necessary(config: &Config) -> bool {
if let Some(tracing_config) = config.tracing_config.as_ref() {
tracing_init::init_tracing(tracing_config);
true
} else {
false
}
}
fn register_user_defined_callbacks(config: &mut Config) {
let mut user_defined_event_callback = UserDefinedEventCallback::new();
if config.test_user_defined_callback {
user_defined_event_callback.enable = true;
}
if user_defined_event_callback.is_enabled() {
config.event_manager.register_callback(
EventType::ALL_EVENTS,
user_defined_event_callback,
config.dry_run,
);
}
let mut user_defined_filter_callback = UserDefinedFilterCallback::new();
if config.test_user_defined_callback {
user_defined_filter_callback.enable = true;
}
if user_defined_filter_callback.is_enabled() {
config
.filter_manager
.register_callback(user_defined_filter_callback);
}
}
async fn run(mut config: Config) -> Result<()> {
register_user_defined_callbacks(&mut config);
#[allow(unused_assignments)]
let mut has_warning = false;
{
let cancellation_token = create_pipeline_cancellation_token();
let start_time = tokio::time::Instant::now();
debug!("deletion pipeline start.");
let mut pipeline = DeletionPipeline::new(config.clone(), cancellation_token.clone()).await;
if let Err(e) = pipeline.check_prerequisites().await {
pipeline.close_stats_sender();
if is_cancelled_error(&e) {
println!("Deletion cancelled.");
debug!("deletion cancelled by user.");
return Ok(());
}
let code = exit_code_from_error(&e);
error!("{}", e);
std::process::exit(code);
}
ctrl_c_handler::spawn_ctrl_c_handler(cancellation_token);
let indicator_join_handle = indicator::show_indicator(
pipeline.get_stats_receiver(),
ui_config::is_progress_indicator_needed(&config),
ui_config::is_show_result_needed(&config),
config.dry_run,
);
pipeline.run().await;
match indicator_join_handle.await {
Ok(_summary) => {}
Err(e) => {
error!("indicator task panicked: {}", e);
std::process::exit(EXIT_CODE_ABNORMAL_TERMINATION);
}
}
let duration_sec = format!("{:.3}", start_time.elapsed().as_secs_f32());
if pipeline.has_error() {
if pipeline.has_panic() {
error!(duration_sec = duration_sec, "s3rm abnormal termination.");
std::process::exit(EXIT_CODE_ABNORMAL_TERMINATION);
}
let Some(errors) = pipeline.get_errors_and_consume() else {
error!(duration_sec = duration_sec, "s3rm failed.");
std::process::exit(1);
};
let mut code = 1;
for err in &errors {
if is_cancelled_error(err) {
debug!("deletion cancelled by user.");
return Ok(());
}
code = code.max(exit_code_from_error(err));
error!("{}", err);
}
error!(duration_sec = duration_sec, "s3rm failed.");
std::process::exit(code);
}
has_warning = pipeline.has_warning();
debug!(duration_sec = duration_sec, "s3rm has been completed.");
}
if has_warning {
std::process::exit(EXIT_CODE_WARNING);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusty_fork::rusty_fork_test;
use s3rm_rs::config::args::parse_from_args;
rusty_fork_test! {
#[test]
fn with_tracing() {
let args = vec![
"s3rm",
"-v",
"s3://test-bucket/prefix/",
];
let config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
assert!(start_tracing_if_necessary(&config));
}
#[test]
fn without_tracing() {
let args = vec![
"s3rm",
"-qq",
"s3://test-bucket/prefix/",
];
let config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
assert!(!start_tracing_if_necessary(&config));
}
}
#[test]
fn start_tracing_with_none_config() {
let args = vec!["s3rm", "-qq", "s3://test-bucket/prefix/"];
let config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
assert!(config.tracing_config.is_none());
assert!(!start_tracing_if_necessary(&config));
}
#[test]
fn register_user_defined_callbacks_enabled() {
let args = vec!["s3rm", "-f", "s3://test-bucket/prefix/"];
let mut config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
config.test_user_defined_callback = true;
assert!(!config.event_manager.is_callback_registered());
assert!(!config.filter_manager.is_callback_registered());
register_user_defined_callbacks(&mut config);
assert!(
config.event_manager.is_callback_registered(),
"Event callback should be registered when test_user_defined_callback is true"
);
assert!(
config.filter_manager.is_callback_registered(),
"Filter callback should be registered when test_user_defined_callback is true"
);
}
#[test]
fn register_user_defined_callbacks_disabled_by_default() {
let args = vec!["s3rm", "-f", "s3://test-bucket/prefix/"];
let mut config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
assert!(!config.test_user_defined_callback);
register_user_defined_callbacks(&mut config);
assert!(
!config.event_manager.is_callback_registered(),
"Event callback should NOT be registered by default"
);
assert!(
!config.filter_manager.is_callback_registered(),
"Filter callback should NOT be registered by default"
);
}
#[tokio::test]
async fn pipeline_run_errors_on_unreachable_endpoint() {
let args = vec![
"s3rm",
"-f",
"--target-access-key",
"dummy",
"--target-secret-access-key",
"dummy",
"--target-endpoint-url",
"https://anything.invalid",
"--aws-config-file",
"./test_data/test_config/config",
"--aws-shared-credentials-file",
"./test_data/test_config/credentials",
"--connect-timeout-milliseconds",
"1",
"--aws-max-attempts",
"0",
"s3://test-bucket/prefix/",
];
let config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
let cancellation_token = create_pipeline_cancellation_token();
let mut pipeline = DeletionPipeline::new(config, cancellation_token).await;
pipeline.run().await;
assert!(
pipeline.has_error(),
"Pipeline should have errors when S3 endpoint is unreachable"
);
assert_eq!(
pipeline.get_deletion_stats().stats_deleted_objects,
0,
"No objects should be deleted when endpoint is unreachable"
);
}
#[tokio::test]
async fn pipeline_run_dry_run_errors_on_unreachable_endpoint() {
let args = vec![
"s3rm",
"-f",
"-d",
"--target-access-key",
"dummy",
"--target-secret-access-key",
"dummy",
"--target-endpoint-url",
"https://anything.invalid",
"--aws-config-file",
"./test_data/test_config/config",
"--aws-shared-credentials-file",
"./test_data/test_config/credentials",
"--connect-timeout-milliseconds",
"1",
"--aws-max-attempts",
"0",
"s3://test-bucket/prefix/",
];
let config = Config::try_from(parse_from_args(args).unwrap()).unwrap();
assert!(config.dry_run);
let cancellation_token = create_pipeline_cancellation_token();
let mut pipeline = DeletionPipeline::new(config, cancellation_token).await;
pipeline.run().await;
assert!(
pipeline.has_error(),
"Pipeline should have errors even in dry-run when listing fails"
);
}
}