filelift 0.1.1

A small CLI for lifting local files to S3-compatible object storage.
use std::io::{self, Write};

use anyhow::{Context, Result};

use crate::{
    cli::{TargetAddCommand, TargetCommands, TargetRemoveCommand, TargetUseCommand},
    diagnostic_log, i18n, output, secret, storage,
    target::{TargetStore, UploadTarget},
};

pub async fn run(command: TargetCommands) -> Result<()> {
    match command {
        TargetCommands::Add(command) => add(command).await,
        TargetCommands::List => list(),
        TargetCommands::Use(command) => use_target(command),
        TargetCommands::Remove(command) => remove(command),
    }
}

async fn add(command: TargetAddCommand) -> Result<()> {
    let mut store = TargetStore::load()?;
    let name = command.name;
    let should_prompt_region =
        command.bucket.is_none() || command.endpoint.is_none() || command.public_base_url.is_none();
    let bucket = prompt_required(command.bucket, &i18n::t("prompt-bucket"))?;
    let endpoint = prompt_required(command.endpoint, &i18n::t("prompt-endpoint"))?;
    let region = if should_prompt_region {
        prompt_with_default(command.region, &i18n::t("prompt-region"), "auto")?
    } else {
        command.region.unwrap_or_else(|| "auto".to_string())
    };
    let public_base_url =
        prompt_required(command.public_base_url, &i18n::t("prompt-public-base-url"))?;
    let public_base_url = output::normalize_public_base_url(&public_base_url)?;
    let credentials = prompt_credentials(
        command.access_key_id,
        command.secret_access_key,
        should_prompt_region,
    )?;
    let connectivity_credentials =
        resolve_connectivity_credentials(credentials.clone(), || secret::credentials(&name))?;
    let target = UploadTarget {
        provider: command.provider,
        bucket,
        endpoint,
        region,
        public_base_url,
    };

    check_target_connectivity(&target, connectivity_credentials, command.skip_check).await?;

    store.targets.insert(name.clone(), target);

    if let Some((access_key_id, secret_access_key)) = credentials {
        secret::set_credentials(&name, &access_key_id, &secret_access_key)?;
    }

    if command.set_default || store.default_target.is_none() {
        store.default_target = Some(name.clone());
    }

    store.save()?;
    diagnostic_log::record_command_result("target add", Some(&name), "success");
    println!("{}", i18n::t_args("target-added", &[("name", &name)]));
    Ok(())
}

async fn check_target_connectivity(
    target: &UploadTarget,
    credentials: Option<secret::Credentials>,
    skip_check: bool,
) -> Result<()> {
    if skip_check {
        return Ok(());
    }

    let Some(credentials) = credentials else {
        println!("{}", i18n::t("target-connectivity-skipped-no-credentials"));
        return Ok(());
    };

    println!("{}", i18n::t("target-checking-connectivity"));
    let client = storage::s3::Client::new(target.clone(), credentials).await?;
    client.check_connectivity().await?;
    println!("{}", i18n::t("target-connectivity-passed"));
    Ok(())
}

fn resolve_connectivity_credentials(
    provided: Option<(String, String)>,
    load_saved: impl FnOnce() -> Result<secret::Credentials>,
) -> Result<Option<secret::Credentials>> {
    if let Some((access_key_id, secret_access_key)) = provided {
        return Ok(Some(secret::Credentials {
            access_key_id,
            secret_access_key,
        }));
    }

    Ok(load_saved().ok())
}

fn prompt_required(value: Option<String>, label: &str) -> Result<String> {
    if let Some(value) = value {
        return Ok(value);
    }

    loop {
        let answer = prompt_line(&format!("{label}: "))?;
        if !answer.is_empty() {
            return Ok(answer);
        }

        println!(
            "{}",
            i18n::t_args("prompt-cannot-be-empty", &[("label", label)])
        );
    }
}

fn prompt_with_default(value: Option<String>, label: &str, default: &str) -> Result<String> {
    if let Some(value) = value {
        return Ok(value);
    }

    let answer = prompt_line(&format!("{label} [{default}]: "))?;
    if answer.is_empty() {
        Ok(default.to_string())
    } else {
        Ok(answer)
    }
}

fn prompt_credentials(
    access_key_id: Option<String>,
    secret_access_key: Option<String>,
    should_prompt_empty_credentials: bool,
) -> Result<Option<(String, String)>> {
    match (access_key_id, secret_access_key) {
        (Some(access_key_id), Some(secret_access_key)) => {
            Ok(Some((access_key_id, secret_access_key)))
        }
        (Some(access_key_id), None) => Ok(Some((
            access_key_id,
            prompt_password(&i18n::t("prompt-secret-access-key"))?,
        ))),
        (None, Some(secret_access_key)) => Ok(Some((
            prompt_required(None, &i18n::t("prompt-access-key-id"))?,
            secret_access_key,
        ))),
        (None, None) if should_prompt_empty_credentials => {
            if prompt_yes_no(&i18n::t("prompt-save-access-keys-now"), true)? {
                Ok(Some((
                    prompt_required(None, &i18n::t("prompt-access-key-id"))?,
                    prompt_password(&i18n::t("prompt-secret-access-key"))?,
                )))
            } else {
                Ok(None)
            }
        }
        (None, None) => Ok(None),
    }
}

fn prompt_yes_no(label: &str, default: bool) -> Result<bool> {
    loop {
        let answer = prompt_line(label)?;
        if let Some(value) = parse_yes_no_answer(&answer, default) {
            return Ok(value);
        }

        println!("{}", i18n::t("prompt-please-answer-yes-no"));
    }
}

fn parse_yes_no_answer(answer: &str, default: bool) -> Option<bool> {
    match answer.to_ascii_lowercase().as_str() {
        "" => Some(default),
        "n" | "no" => Some(false),
        "y" | "yes" => Some(true),
        _ => None,
    }
}

fn prompt_password(label: &str) -> Result<String> {
    let config = rpassword::ConfigBuilder::new()
        .password_feedback_mask('*')
        .build();
    rpassword::prompt_password_with_config(format!("{label}: "), config)
        .with_context(|| format!("failed to read {label}"))
}

fn prompt_line(label: &str) -> Result<String> {
    print!("{label}");
    io::stdout().flush().context("failed to flush prompt")?;

    let mut answer = String::new();
    let bytes_read = io::stdin()
        .read_line(&mut answer)
        .context("failed to read prompt input")?;
    if bytes_read == 0 {
        anyhow::bail!(
            "prompt input ended before `{}` was provided",
            label.trim_end_matches(": ").trim()
        );
    }

    Ok(answer.trim().to_string())
}

fn list() -> Result<()> {
    let store = TargetStore::load()?;
    if store.targets.is_empty() {
        diagnostic_log::record_command_result("target list", None, "success");
        println!("{}", i18n::t("target-no-targets-configured"));
        return Ok(());
    }

    for name in store.targets.keys() {
        let marker = if store.default_target.as_deref() == Some(name.as_str()) {
            "*"
        } else {
            " "
        };
        println!("{marker} {name}");
    }

    diagnostic_log::record_command_result("target list", None, "success");
    Ok(())
}

fn use_target(command: TargetUseCommand) -> Result<()> {
    let mut store = TargetStore::load()?;
    store
        .targets
        .contains_key(&command.name)
        .then_some(())
        .with_context(|| format!("target `{}` does not exist", command.name))?;

    store.default_target = Some(command.name.clone());
    store.save()?;
    diagnostic_log::record_command_result("target use", Some(&command.name), "success");
    println!(
        "{}",
        i18n::t_args("target-using", &[("name", &command.name)])
    );
    Ok(())
}

fn remove(command: TargetRemoveCommand) -> Result<()> {
    let mut store = TargetStore::load()?;
    store
        .targets
        .remove(&command.name)
        .with_context(|| format!("target `{}` does not exist", command.name))?;

    if store.default_target.as_deref() == Some(command.name.as_str()) {
        store.default_target = None;
    }

    secret::delete_credentials(&command.name)?;
    store.save()?;
    diagnostic_log::record_command_result("target remove", Some(&command.name), "success");
    println!(
        "{}",
        i18n::t_args("target-removed", &[("name", &command.name)])
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn provided_credentials_are_used_for_connectivity_check() {
        let credentials = resolve_connectivity_credentials(
            Some(("new-id".to_string(), "new-secret".to_string())),
            || anyhow::bail!("saved credentials should not be loaded"),
        )
        .unwrap()
        .unwrap();

        assert_eq!(credentials.access_key_id, "new-id");
        assert_eq!(credentials.secret_access_key, "new-secret");
    }

    #[test]
    fn saved_credentials_are_reused_for_connectivity_check() {
        let credentials = resolve_connectivity_credentials(None, || {
            Ok(secret::Credentials {
                access_key_id: "saved-id".to_string(),
                secret_access_key: "saved-secret".to_string(),
            })
        })
        .unwrap()
        .unwrap();

        assert_eq!(credentials.access_key_id, "saved-id");
        assert_eq!(credentials.secret_access_key, "saved-secret");
    }

    #[test]
    fn missing_saved_credentials_skip_connectivity_check() {
        let credentials =
            resolve_connectivity_credentials(None, || anyhow::bail!("missing saved credentials"))
                .unwrap();

        assert!(credentials.is_none());
    }

    #[test]
    fn empty_yes_no_answer_uses_default() {
        assert_eq!(parse_yes_no_answer("", true), Some(true));
        assert_eq!(parse_yes_no_answer("", false), Some(false));
    }
}