use std::{
fs,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};
use indicatif::ProgressBar;
use tokio::task::JoinSet;
use crate::{
cli::{OutputFormat, UploadCommand},
i18n, output, secret, storage,
target::TargetStore,
};
const UPLOAD_CONCURRENCY: usize = 8;
pub async fn run(command: UploadCommand) -> Result<()> {
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 folder = resolve_upload_folder(
target.folder.as_deref(),
command.folder.as_deref(),
command.ignore_target_folder,
)?;
let items = plan_uploads(&command.path, folder.as_deref(), command.name.as_deref())?;
if items.is_empty() {
bail!("no files matched upload input");
}
let upload_count = items.len() as u64;
tracing::info!(
command = "upload",
target = %target_name,
dry_run,
markdown,
upload_count,
result = "planned",
"upload planned"
);
let credentials = if 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(Arc::new(
storage::s3::Client::new(target.clone(), credentials).await?,
))
} else {
None
};
let progress = if dry_run {
None
} else {
Some(output::upload_progress(upload_count))
};
if let Some(client) = &client
&& let Err(error) = upload_items(
client.clone(),
&items,
progress.as_ref(),
UPLOAD_CONCURRENCY,
)
.await
{
if let Some(progress) = &progress {
progress.finish_and_clear();
}
return Err(error);
}
if let Some(progress) = &progress {
progress.finish_and_clear();
}
if dry_run {
anstream::eprintln!(
"{}",
output::info(&format!(
"Dry run: {upload_count} file(s) planned for target `{target_name}` (nothing uploaded)."
))
);
for item in &items {
anstream::eprintln!(" {} -> {}", item.local_path, item.key);
}
}
match command.output {
OutputFormat::Text => {
for item in &items {
let url = output::public_url(target, &item.key)?;
if 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}");
}
}
}
OutputFormat::Json => {
let entries = items
.iter()
.map(|item| {
let url = output::public_url(target, &item.key)?;
Ok(serde_json::json!({
"local_path": item.local_path.as_str(),
"key": item.key,
"url": url,
}))
})
.collect::<Result<Vec<_>>>()?;
let document = serde_json::json!({
"target": target_name,
"dry_run": dry_run,
"count": items.len(),
"uploads": entries,
});
println!(
"{}",
serde_json::to_string_pretty(&document)
.context("failed to serialize upload result as JSON")?
);
}
}
if !dry_run {
anstream::eprintln!(
"{}",
output::success(&format!("Uploaded {upload_count} file(s)."))
);
}
tracing::info!(
command = "upload",
target = %target_name,
dry_run,
markdown,
upload_count,
result = "success",
"upload finished"
);
Ok(())
}
async fn upload_items(
client: Arc<storage::s3::Client>,
items: &[UploadItem],
progress: Option<&ProgressBar>,
concurrency: usize,
) -> Result<()> {
let mut set: JoinSet<Result<()>> = JoinSet::new();
let mut pending = items.iter().cloned();
for _ in 0..concurrency.max(1) {
let Some(item) = pending.next() else {
break;
};
spawn_upload(&mut set, client.clone(), item, progress.cloned());
}
while let Some(joined) = set.join_next().await {
joined.context("upload task failed to complete")??;
if let Some(item) = pending.next() {
spawn_upload(&mut set, client.clone(), item, progress.cloned());
}
}
Ok(())
}
fn spawn_upload(
set: &mut JoinSet<Result<()>>,
client: Arc<storage::s3::Client>,
item: UploadItem,
progress: Option<ProgressBar>,
) {
set.spawn(async move {
if let Some(progress) = &progress {
progress.set_message(format!("Uploading {}", item.key));
}
client.upload_file(&item.local_path, &item.key).await?;
if let Some(progress) = &progress {
progress.inc(1);
}
Ok(())
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct UploadItem {
local_path: Utf8PathBuf,
key: String,
}
fn plan_uploads(
path: &Utf8Path,
prefix: Option<&str>,
name: Option<&str>,
) -> 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");
}
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(),
}
}
fn resolve_upload_folder(
target_folder: Option<&str>,
upload_folder: Option<&str>,
ignore_target_folder: bool,
) -> Result<Option<String>> {
let now = DateParts::today_utc();
let mut parts = Vec::new();
if !ignore_target_folder && let Some(folder) = normalize_folder(target_folder, now)? {
parts.push(folder);
}
if let Some(folder) = normalize_folder(upload_folder, now)? {
parts.push(folder);
}
if parts.is_empty() {
Ok(None)
} else {
Ok(Some(parts.join("/")))
}
}
fn normalize_folder(folder: Option<&str>, today: DateParts) -> Result<Option<String>> {
let Some(folder) = folder.map(str::trim).filter(|folder| !folder.is_empty()) else {
return Ok(None);
};
let rendered = render_folder_template(folder, today);
let rendered = rendered.replace('\\', "/");
let normalized = rendered
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if normalized.contains(&"..") {
bail!("folder cannot contain `..` path segments");
}
Ok(Some(normalized.join("/")))
}
#[derive(Debug, Clone, Copy)]
struct DateParts {
year: i32,
month: u32,
day: u32,
}
impl DateParts {
fn today_utc() -> Self {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self::from_unix_days((seconds / 86_400) as i64)
}
fn from_unix_days(days: i64) -> Self {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let mut year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
Self {
year: year as i32,
month: month as u32,
day: day as u32,
}
}
}
fn render_folder_template(template: &str, today: DateParts) -> String {
template
.replace("{yyyy}", &format!("{:04}", today.year))
.replace("{MM}", &format!("{:02}", today.month))
.replace("{dd}", &format!("{:02}", today.day))
.replace(
"{date}",
&format!("{:04}-{:02}-{:02}", today.year, today.month, today.day),
)
}
#[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).unwrap();
assert_eq!(
items,
vec![UploadItem {
local_path: file_path,
key: "blog/post/cover.webp".to_string(),
}]
);
}
#[test]
fn combines_target_folder_and_upload_folder() {
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 folder = resolve_upload_folder(Some("blog"), Some("posts/2026/06/08"), false).unwrap();
let items = plan_uploads(&file_path, folder.as_deref(), None).unwrap();
assert_eq!(items[0].key, "blog/posts/2026/06/08/cover.webp");
}
#[test]
fn ignore_target_folder_skips_target_base_folder() {
let folder = resolve_upload_folder(Some("blog"), Some("posts/2026"), true).unwrap();
assert_eq!(folder.as_deref(), Some("posts/2026"));
}
#[test]
fn rejects_parent_directory_segments_in_folder() {
let error = resolve_upload_folder(Some("blog"), Some("../private"), false)
.unwrap_err()
.to_string();
assert!(error.contains("folder"));
assert!(error.contains(".."));
}
#[test]
fn renders_basic_date_folder_template() {
let folder = render_folder_template(
"posts/{yyyy}/{MM}/{dd}/{date}",
DateParts {
year: 2026,
month: 6,
day: 8,
},
);
assert_eq!(folder, "posts/2026/06/08/2026-06-08");
}
#[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")).unwrap();
assert_eq!(items[0].key, "blog/post/cover.webp");
}
#[test]
fn rejects_custom_name_for_directory_upload() {
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, Some("cover.webp"))
.unwrap_err()
.to_string();
assert!(error.contains("--name"));
}
#[test]
fn plans_directory_upload_recursively_by_default() {
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).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(),
]
);
}
}