filelift 0.1.2

A small CLI for lifting local files to S3-compatible object storage.
use std::fs;

use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};

use crate::{cli::UploadCommand, i18n, output, secret, storage, target::TargetStore};

pub async fn run(command: UploadCommand) -> Result<()> {
    let recursive = command.recursive;
    let dry_run = command.dry_run;
    let markdown = command.markdown;
    let store = TargetStore::load()?;
    let target_name = store.active_target_name(command.target.as_deref())?;
    let target = store
        .targets
        .get(&target_name)
        .with_context(|| format!("target `{target_name}` does not exist"))?;

    let items = plan_uploads(
        &command.path,
        command.prefix.as_deref(),
        command.name.as_deref(),
        command.recursive,
    )?;
    if items.is_empty() {
        bail!("no files matched upload input");
    }
    let upload_count = items.len() as u64;
    tracing::info!(
        command = "upload",
        target = %target_name,
        recursive,
        dry_run,
        markdown,
        upload_count,
        result = "planned",
        "upload planned"
    );

    let credentials = if command.dry_run {
        None
    } else {
        Some(secret::credentials(&target_name).with_context(|| {
            i18n::t_args("upload-missing-credentials", &[("target", &target_name)])
        })?)
    };

    let client = if let Some(credentials) = credentials {
        Some(storage::s3::Client::new(target.clone(), credentials).await?)
    } else {
        None
    };

    for item in items {
        let url = output::public_url(target, &item.key)?;

        if let Some(client) = &client {
            client.upload_file(&item.local_path, &item.key).await?;
        }

        if command.markdown {
            let label = item
                .local_path
                .file_stem()
                .unwrap_or_else(|| item.local_path.as_str());
            println!("{}", output::markdown_image(label, &url));
        } else {
            println!("{url}");
        }
    }

    tracing::info!(
        command = "upload",
        target = %target_name,
        recursive,
        dry_run,
        markdown,
        upload_count,
        result = "success",
        "upload finished"
    );

    Ok(())
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct UploadItem {
    local_path: Utf8PathBuf,
    key: String,
}

fn plan_uploads(
    path: &Utf8Path,
    prefix: Option<&str>,
    name: Option<&str>,
    recursive: bool,
) -> Result<Vec<UploadItem>> {
    if path.is_file() {
        let filename = name
            .or_else(|| path.file_name())
            .context("could not infer file name")?;
        return Ok(vec![UploadItem {
            local_path: path.to_path_buf(),
            key: join_key(prefix, filename),
        }]);
    }

    if path.is_dir() {
        if name.is_some() {
            bail!("--name can only be used with a single file");
        }
        if !recursive {
            bail!("refusing to upload a directory without --recursive");
        }

        let mut items = Vec::new();
        collect_directory(path, path, prefix, &mut items)?;
        items.sort_by(|left, right| left.key.cmp(&right.key));
        return Ok(items);
    }

    bail!("path does not exist: {path}")
}

fn collect_directory(
    root: &Utf8Path,
    current: &Utf8Path,
    prefix: Option<&str>,
    items: &mut Vec<UploadItem>,
) -> Result<()> {
    for entry in fs::read_dir(current).with_context(|| format!("failed to read {current}"))? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(|path| {
            anyhow::anyhow!("path contains non-UTF-8 characters: {}", path.display())
        })?;

        if path.is_dir() {
            collect_directory(root, &path, prefix, items)?;
        } else if path.is_file() {
            let relative = path
                .strip_prefix(root)
                .with_context(|| format!("failed to make {path} relative to {root}"))?;
            let key = join_key(prefix, &relative.as_str().replace('\\', "/"));
            items.push(UploadItem {
                local_path: path,
                key,
            });
        }
    }
    Ok(())
}

fn join_key(prefix: Option<&str>, name: &str) -> String {
    match prefix.map(str::trim).filter(|prefix| !prefix.is_empty()) {
        Some(prefix) => format!(
            "{}/{}",
            prefix.trim_matches('/'),
            name.trim_start_matches('/')
        ),
        None => name.trim_start_matches('/').to_string(),
    }
}

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

    #[test]
    fn joins_key_with_clean_prefix() {
        assert_eq!(
            join_key(Some("/blog/post/"), "cover.webp"),
            "blog/post/cover.webp"
        );
        assert_eq!(join_key(None, "/cover.webp"), "cover.webp");
    }

    #[test]
    fn plans_single_file_upload_with_prefix() {
        let tempdir = tempfile::tempdir().unwrap();
        let file_path = tempdir.path().join("cover.webp");
        fs::write(&file_path, "image").unwrap();
        let file_path = Utf8PathBuf::from_path_buf(file_path).unwrap();

        let items = plan_uploads(&file_path, Some("blog/post"), None, false).unwrap();

        assert_eq!(
            items,
            vec![UploadItem {
                local_path: file_path,
                key: "blog/post/cover.webp".to_string(),
            }]
        );
    }

    #[test]
    fn plans_single_file_upload_with_custom_name() {
        let tempdir = tempfile::tempdir().unwrap();
        let file_path = tempdir.path().join("cover-original.webp");
        fs::write(&file_path, "image").unwrap();
        let file_path = Utf8PathBuf::from_path_buf(file_path).unwrap();

        let items = plan_uploads(&file_path, Some("blog/post"), Some("cover.webp"), false).unwrap();

        assert_eq!(items[0].key, "blog/post/cover.webp");
    }

    #[test]
    fn refuses_directory_without_recursive_flag() {
        let tempdir = tempfile::tempdir().unwrap();
        let dir_path = Utf8PathBuf::from_path_buf(tempdir.path().to_path_buf()).unwrap();

        let error = plan_uploads(&dir_path, None, None, false)
            .unwrap_err()
            .to_string();

        assert!(error.contains("--recursive"));
    }

    #[test]
    fn plans_recursive_directory_upload_with_relative_keys() {
        let tempdir = tempfile::tempdir().unwrap();
        let nested = tempdir.path().join("images").join("nested");
        fs::create_dir_all(&nested).unwrap();
        fs::write(tempdir.path().join("images").join("cover.webp"), "image").unwrap();
        fs::write(nested.join("demo.mp4"), "video").unwrap();
        let dir_path = Utf8PathBuf::from_path_buf(tempdir.path().join("images")).unwrap();

        let items = plan_uploads(&dir_path, Some("blog/post"), None, true).unwrap();
        let keys = items.into_iter().map(|item| item.key).collect::<Vec<_>>();

        assert_eq!(
            keys,
            vec![
                "blog/post/cover.webp".to_string(),
                "blog/post/nested/demo.mp4".to_string(),
            ]
        );
    }
}