transfer_family_cli 0.1.0

TUI to browse and transfer files via AWS Transfer Family connector
Documentation
//! Transfer Family Connector TUI CLI – SFTP-like browser via AWS Transfer API.

pub mod config;
pub mod error;
pub mod listing;
pub mod local_fs;
pub mod retry;
pub mod transfer;
pub mod transfer_commands;
pub mod transfer_storage;
pub mod tui;

use aws_config::BehaviorVersion;
pub use config::Config;
pub use error::Result;

use listing::list_directory;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io;
use std::path::PathBuf;
use tokio::sync::mpsc;

use local_fs::LocalFs;
use transfer_commands::TransferCommands;
use transfer_storage::TransferStorage;

/// Runs the TUI event loop with AWS-backed services (loads config, creates clients).
pub async fn run(config: Config) -> Result<()> {
    let aws_config = load_aws_config(&config).await;
    let transfer =
        transfer_commands::AwsTransferCommands::new(aws_sdk_transfer::Client::new(&aws_config));
    let storage = transfer_storage::AwsTransferStorage::new(aws_sdk_s3::Client::new(&aws_config));
    let local_fs = local_fs::TokioLocalFs::new();
    run_with(&config, &transfer, &storage, &local_fs).await
}

/// Runs the TUI event loop with the given transfer, storage, and local-fs implementations.
pub async fn run_with<TC, TS, LF>(
    config: &Config,
    transfer: &TC,
    storage: &TS,
    local_fs: &LF,
) -> Result<()>
where
    TC: TransferCommands + Clone + Send + Sync + 'static,
    TS: TransferStorage + Clone + Send + Sync + 'static,
    LF: LocalFs + Clone + Send + Sync + 'static,
{
    crossterm::terminal::enable_raw_mode().map_err(|e| {
        error::Error::io("enable_raw_mode failed", e).with("operation", "terminal_init")
    })?;
    let mut stdout = io::stdout();
    crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen).map_err(|e| {
        error::Error::io("EnterAlternateScreen failed", e).with("operation", "terminal_init")
    })?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend).map_err(|e| {
        error::Error::io("Terminal::new failed", e).with("operation", "terminal_init")
    })?;

    let (action_tx, mut action_rx) = mpsc::unbounded_channel::<tui::UserAction>();

    let mut state = tui::AppState::new(config);

    loop {
        terminal.draw(|f| tui::draw(f, &state, config))?;

        let action_opt = poll_action(&mut action_rx, &mut state)?;

        if let Some(action) = action_opt
            && dispatch_action(
                action, &mut state, config, transfer, storage, local_fs, &action_tx,
            )
            .await
        {
            break;
        }
    }

    crossterm::terminal::disable_raw_mode().map_err(|e| {
        error::Error::io("disable_raw_mode failed", e).with("operation", "terminal_teardown")
    })?;
    crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen).map_err(|e| {
        error::Error::io("LeaveAlternateScreen failed", e).with("operation", "terminal_teardown")
    })?;

    Ok(())
}

/// Polls for the next action: channel first, then keyboard if none pending.
fn poll_action(
    action_rx: &mut mpsc::UnboundedReceiver<tui::UserAction>,
    state: &mut tui::AppState,
) -> Result<Option<tui::UserAction>> {
    if let Ok(action) = action_rx.try_recv() {
        return Ok(Some(action));
    }
    if crossterm::event::poll(std::time::Duration::from_millis(100))
        .map_err(|e| error::Error::io("event poll failed", e).with("operation", "poll_action"))?
        && let crossterm::event::Event::Key(key) = crossterm::event::read().map_err(|e| {
            error::Error::io("event read failed", e).with("operation", "poll_action")
        })?
        && key.kind == crossterm::event::KeyEventKind::Press
    {
        return Ok(handle_key(state, key.code, key.modifiers));
    }
    Ok(None)
}

/// Handles a key press; returns optional `UserAction` (Enter submits command).
fn handle_key(
    state: &mut tui::AppState,
    code: crossterm::event::KeyCode,
    modifiers: crossterm::event::KeyModifiers,
) -> Option<tui::UserAction> {
    use crossterm::event::{KeyCode, KeyModifiers};
    match (code, modifiers) {
        (KeyCode::Enter, KeyModifiers::NONE) => {
            if state.cmd_buffer.trim().is_empty() {
                state.cmd_buffer.clear();
                return None;
            }
            let action = state.cmd_buffer.parse::<tui::UserAction>();
            state.cmd_buffer.clear();
            match action {
                Ok(act) => Some(act),
                Err(e) => {
                    state.status_message = e;
                    None
                }
            }
        }
        (KeyCode::Esc, KeyModifiers::NONE) => {
            state.cmd_buffer.clear();
            None
        }
        (KeyCode::Backspace, KeyModifiers::NONE) => {
            state.cmd_buffer.pop();
            None
        }
        (KeyCode::Char(c), KeyModifiers::NONE) => {
            state.cmd_buffer.push(c);
            None
        }
        (KeyCode::Up, KeyModifiers::NONE) => Some(tui::UserAction::CursorUp),
        (KeyCode::Down, KeyModifiers::NONE) => Some(tui::UserAction::CursorDown),
        _ => None,
    }
}

/// Dispatches one `UserAction`; returns true if the app should quit.
#[allow(clippy::too_many_arguments)]
async fn dispatch_action<TC, TS, LF>(
    action: tui::UserAction,
    state: &mut tui::AppState,
    config: &Config,
    transfer: &TC,
    storage: &TS,
    local_fs: &LF,
    action_tx: &mpsc::UnboundedSender<tui::UserAction>,
) -> bool
where
    TC: TransferCommands + Clone + Send + Sync + 'static,
    TS: TransferStorage + Clone + Send + Sync + 'static,
    LF: LocalFs + Clone + Send + Sync + 'static,
{
    match action {
        tui::UserAction::Quit => return true,
        tui::UserAction::CursorUp => {
            state.selected_index = state.selected_index.saturating_sub(1);
        }
        tui::UserAction::CursorDown => {
            let max = state.entries_for_display().len();
            if max > 0 && state.selected_index < max - 1 {
                state.selected_index += 1;
            }
        }
        tui::UserAction::CdUp => {
            state.current_remote_path = parent_path(&state.current_remote_path);
            state.selected_index = 0;
            state.status_message.clear();
        }
        tui::UserAction::CdIn => {
            if let Some(dir_path) = state.selected_dir_path() {
                state.current_remote_path = dir_path;
                state.selected_index = 0;
                state.status_message.clear();
            } else {
                state.status_message =
                    "no directory selected; use ↑/↓ then cd or cd <name>".to_string();
            }
        }
        tui::UserAction::CdTo(dirname) => {
            if let Some(full_path) = state.resolve_dir_name(&dirname) {
                state.current_remote_path = full_path;
                state.selected_index = 0;
                state.status_message.clear();
            } else {
                state.status_message = format!("no such directory: {dirname}");
            }
        }
        tui::UserAction::Refresh => {
            state.set_loading("Listing...");
            let path = state.current_remote_path.clone();
            let cfg = config.clone();
            let tc = transfer.clone();
            let sc = storage.clone();
            let tx = action_tx.clone();
            tokio::spawn(async move {
                let res = list_directory(&tc, &sc, &cfg, &path, Some(1000)).await;
                let msg = tui::UserAction::AsyncResult(tui::AsyncResultAction::Listing(res));
                drop(tx.send(msg));
            });
        }
        tui::UserAction::AsyncResult(a) => a.apply(state),
        tui::UserAction::Status(msg) => {
            state.clear_loading();
            state.status_message = msg;
        }
        tui::UserAction::Get => {
            if let Some(remote_path) = state.selected_file_path() {
                spawn_get(
                    state,
                    config,
                    transfer,
                    storage,
                    local_fs,
                    action_tx,
                    &remote_path,
                );
            } else {
                state.status_message =
                    "no file selected; use ↑/↓ then get or get <remote_path>".to_string();
            }
        }
        tui::UserAction::GetFile(remote_path) => {
            spawn_get(
                state,
                config,
                transfer,
                storage,
                local_fs,
                action_tx,
                &remote_path,
            );
        }
        tui::UserAction::Put(local_path_str) => {
            let local_path = PathBuf::from(local_path_str.trim());
            state.set_loading(format!("Putting {}...", local_path.display()));
            let current = state.current_remote_path.clone();
            let cfg = config.clone();
            let tc = transfer.clone();
            let sc = storage.clone();
            let lf = local_fs.clone();
            let tx = action_tx.clone();
            tokio::spawn(async move {
                let exists = lf.exists(&local_path).await;
                if exists {
                    let res = transfer::put_file(&tc, &sc, &lf, &cfg, &local_path, &current).await;
                    let msg = tui::UserAction::AsyncResult(tui::AsyncResultAction::Put(res));
                    drop(tx.send(msg));
                } else {
                    let msg = tui::UserAction::Status(format!(
                        "File not found: {}",
                        local_path.display()
                    ));
                    drop(tx.send(msg));
                }
            });
        }
    }
    false
}

fn spawn_get<TC, TS, LF>(
    state: &mut tui::AppState,
    config: &Config,
    transfer: &TC,
    storage: &TS,
    local_fs: &LF,
    action_tx: &mpsc::UnboundedSender<tui::UserAction>,
    remote_path: &str,
) where
    TC: TransferCommands + Clone + Send + Sync + 'static,
    TS: TransferStorage + Clone + Send + Sync + 'static,
    LF: LocalFs + Clone + Send + Sync + 'static,
{
    let local_path = state.download_dir.join(
        PathBuf::from(remote_path.trim_start_matches('/'))
            .file_name()
            .unwrap_or(std::ffi::OsStr::new("file")),
    );
    state.set_loading(format!("Getting {}...", remote_path));
    let current = state.current_remote_path.clone();
    let cfg = config.clone();
    let tc = transfer.clone();
    let sc = storage.clone();
    let lf = local_fs.clone();
    let tx = action_tx.clone();
    let remote_path = remote_path.to_string();
    tokio::spawn(async move {
        let res =
            transfer::get_file(&tc, &sc, &lf, &cfg, &remote_path, &local_path, &current).await;
        drop(
            tx.send(tui::UserAction::AsyncResult(tui::AsyncResultAction::Get(
                res,
            ))),
        );
    });
}

fn parent_path(path: &str) -> String {
    let p = path.trim_matches('/');
    if p.is_empty() {
        return "/".to_string();
    }
    let mut parts: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect();
    parts.pop();
    if parts.is_empty() {
        "/".to_string()
    } else {
        format!("/{}/", parts.join("/"))
    }
}

async fn load_aws_config(config: &Config) -> aws_config::SdkConfig {
    let region = config
        .region
        .clone()
        .unwrap_or_else(|| "us-east-1".to_string());
    let profile = config.profile.clone();
    let mut loader =
        aws_config::defaults(BehaviorVersion::latest()).region(aws_config::Region::new(region));
    if let Some(p) = profile {
        loader = loader.profile_name(p);
    }
    loader.load().await
}