use crate::browser::{is_remote, FileBrowser};
use crate::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use std::path::{Path, PathBuf};
const FALLBACK_NAME: &str = "download";
pub struct DownloadPrompt {
pub source: String,
pub input: String,
}
impl DownloadPrompt {
pub fn open(source: &str) -> Self {
Self {
source: source.to_string(),
input: default_filename(source),
}
}
}
pub fn default_filename(source: &str) -> String {
let rest = source.split_once("://").map_or(source, |(_, rest)| rest);
rest.trim_end_matches('/')
.rsplit_once('/')
.map(|(_, name)| name)
.filter(|name| !name.is_empty())
.unwrap_or(FALLBACK_NAME)
.to_string()
}
pub fn resolve_dest(input: &str, source: &str) -> Option<PathBuf> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let expanded = expand_tilde(trimmed);
let names_dir = trimmed.ends_with('/') || trimmed.ends_with(std::path::MAIN_SEPARATOR);
Some(if names_dir || expanded.is_dir() {
expanded.join(default_filename(source))
} else {
expanded
})
}
fn expand_tilde(input: &str) -> PathBuf {
match input
.strip_prefix('~')
.filter(|rest| rest.is_empty() || rest.starts_with(std::path::is_separator))
.zip(dirs::home_dir())
{
Some((rest, home)) => home.join(rest.trim_start_matches(std::path::is_separator)),
None => PathBuf::from(input),
}
}
pub fn download_to(backend: &dyn FileBrowser, source: &str, dest: &Path) -> Result<u64, String> {
if dest.to_str().is_some_and(is_remote) {
return Err("destination must be a local path".to_string());
}
let bytes = backend.download_bytes(source).map_err(|e| e.to_string())?;
if let Some(parent) = dest.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(dest, &bytes).map_err(|e| e.to_string())?;
Ok(bytes.len() as u64)
}
pub fn human_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit + 1 < UNITS.len() {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} B", bytes)
} else {
format!("{:.1} {}", size, UNITS[unit])
}
}
pub fn prompt_line(prompt: &DownloadPrompt, theme: &Theme) -> Line<'static> {
let (text, bg) = match resolve_dest(&prompt.input, &prompt.source) {
Some(dest) if dest.exists() => (
format!(
" d {}_ → {} — ⚠ overwrites Enter overwrite · Esc cancel ",
prompt.input,
dest.display()
),
theme.warn,
),
Some(dest) => (
format!(
" d {}_ → {} Enter save · Esc cancel ",
prompt.input,
dest.display()
),
theme.info,
),
None => (
" d _ (type a destination) Esc cancel ".to_string(),
theme.info,
),
};
Line::from(Span::styled(
text,
Style::default()
.bg(bg)
.fg(theme.bg)
.add_modifier(Modifier::BOLD),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_filename_takes_the_last_segment() {
assert_eq!(default_filename("az://c/data/sales.csv"), "sales.csv");
assert_eq!(
default_filename("s3://bucket/orders.parquet"),
"orders.parquet"
);
}
#[test]
fn default_filename_falls_back_without_a_segment() {
assert_eq!(default_filename("az://container/"), FALLBACK_NAME);
assert_eq!(default_filename(""), FALLBACK_NAME);
}
#[test]
fn prompt_opens_on_the_remote_file_name() {
let prompt = DownloadPrompt::open("az://c/data/sales.csv");
assert_eq!(prompt.input, "sales.csv");
assert_eq!(prompt.source, "az://c/data/sales.csv");
}
#[test]
fn resolve_dest_keeps_a_typed_file_name() {
assert_eq!(
resolve_dest("out.csv", "az://c/sales.csv").unwrap(),
PathBuf::from("out.csv")
);
}
#[test]
fn resolve_dest_keeps_the_extension_as_typed() {
assert_eq!(
resolve_dest("out.bin", "az://c/sales.parquet").unwrap(),
PathBuf::from("out.bin")
);
}
#[test]
fn resolve_dest_appends_the_remote_name_to_a_directory() {
let dir = tempfile::tempdir().unwrap();
let typed = dir.path().to_str().unwrap();
assert_eq!(
resolve_dest(typed, "az://c/data/sales.csv").unwrap(),
dir.path().join("sales.csv")
);
}
#[test]
fn resolve_dest_treats_a_trailing_separator_as_a_directory() {
assert_eq!(
resolve_dest("exports/", "az://c/sales.csv").unwrap(),
PathBuf::from("exports/sales.csv")
);
}
#[test]
fn resolve_dest_trims_input_and_rejects_blank() {
assert_eq!(
resolve_dest(" out.csv ", "az://c/sales.csv").unwrap(),
PathBuf::from("out.csv")
);
assert!(resolve_dest(" ", "az://c/sales.csv").is_none());
assert!(resolve_dest("", "az://c/sales.csv").is_none());
}
#[test]
fn expand_tilde_resolves_the_home_directory() {
let home = dirs::home_dir().expect("invariant: tests run with a home directory");
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("~/out.csv"), home.join("out.csv"));
assert_eq!(
expand_tilde("~root/out.csv"),
PathBuf::from("~root/out.csv")
);
assert_eq!(expand_tilde("./out.csv"), PathBuf::from("./out.csv"));
}
#[test]
#[cfg(windows)]
fn expand_tilde_accepts_a_backslash_on_windows() {
let home = dirs::home_dir().expect("invariant: tests run with a home directory");
assert_eq!(expand_tilde(r"~\out.csv"), home.join("out.csv"));
}
#[test]
#[cfg(not(windows))]
fn expand_tilde_keeps_a_backslash_literal_on_unix() {
assert_eq!(expand_tilde(r"~\out.csv"), PathBuf::from(r"~\out.csv"));
}
#[test]
fn human_size_scales_the_unit() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(812), "812 B");
assert_eq!(human_size(42_188), "41.2 KiB");
assert_eq!(human_size(1_503_238_553), "1.4 GiB");
}
use crate::browser::{BrowserError, Entry};
struct StubBackend {
bytes: Vec<u8>,
}
impl FileBrowser for StubBackend {
fn list(&self, _prefix: &str) -> Result<Vec<Entry>, BrowserError> {
Ok(vec![])
}
fn download_bytes(&self, _path: &str) -> Result<Vec<u8>, BrowserError> {
Ok(self.bytes.clone())
}
}
struct FailingBackend;
impl FileBrowser for FailingBackend {
fn list(&self, _prefix: &str) -> Result<Vec<Entry>, BrowserError> {
Ok(vec![])
}
fn download_bytes(&self, _path: &str) -> Result<Vec<u8>, BrowserError> {
Err(BrowserError::NotFound("no such blob".to_string()))
}
}
#[test]
fn download_to_writes_the_fetched_bytes() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("sales.csv");
let backend = StubBackend {
bytes: b"a,b\n1,2\n".to_vec(),
};
let written = download_to(&backend, "az://c/sales.csv", &dest).unwrap();
assert_eq!(written, 8);
assert_eq!(std::fs::read(&dest).unwrap(), b"a,b\n1,2\n");
}
#[test]
fn download_to_creates_a_missing_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("exports").join("sales.csv");
let backend = StubBackend {
bytes: b"a,b\n".to_vec(),
};
download_to(&backend, "az://c/sales.csv", &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"a,b\n");
}
#[test]
fn download_to_leaves_no_directory_behind_when_the_fetch_fails() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("exports").join("sales.csv");
download_to(&FailingBackend, "az://c/gone.csv", &dest).unwrap_err();
assert!(
!dir.path().join("exports").exists(),
"a failed download should not create the directory"
);
}
#[test]
fn download_to_refuses_a_remote_destination() {
let backend = StubBackend { bytes: vec![] };
for dest in ["az://c/out.csv", "s3://b/out.csv"] {
let err = download_to(&backend, "az://c/sales.csv", Path::new(dest)).unwrap_err();
assert!(err.contains("local path"), "unexpected error: {}", err);
}
}
#[test]
fn download_to_reports_a_backend_failure_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("sales.csv");
let err = download_to(&FailingBackend, "az://c/gone.csv", &dest).unwrap_err();
assert!(err.contains("no such blob"), "unexpected error: {}", err);
assert!(!dest.exists(), "no file should be created on failure");
}
fn prompt_text(prompt: &DownloadPrompt) -> String {
prompt_line(prompt, crate::theme::default_theme())
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn prompt_line_offers_save_and_cancel() {
let dir = tempfile::tempdir().unwrap();
let mut prompt = DownloadPrompt::open("az://c/data/sales.csv");
prompt.input = dir.path().join("fresh.csv").display().to_string();
let text = prompt_text(&prompt);
assert!(text.contains("Enter save"), "missing save hint: {}", text);
assert!(text.contains("Esc cancel"), "missing cancel hint: {}", text);
}
#[test]
fn prompt_line_says_overwrite_when_the_destination_exists() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("sales.csv");
std::fs::write(&dest, b"old").unwrap();
let mut prompt = DownloadPrompt::open("az://c/data/sales.csv");
prompt.input = dest.display().to_string();
let text = prompt_text(&prompt);
assert!(text.contains("⚠ overwrites"), "missing warning: {}", text);
assert!(
text.contains("Enter overwrite"),
"Enter should name the consequence: {}",
text
);
assert!(text.contains("Esc cancel"), "missing cancel hint: {}", text);
}
#[test]
fn prompt_line_offers_only_the_exit_while_blank() {
let mut prompt = DownloadPrompt::open("az://c/data/sales.csv");
prompt.input.clear();
let text = prompt_text(&prompt);
assert!(text.contains("Esc cancel"), "missing cancel hint: {}", text);
assert!(
!text.contains("Enter"),
"Enter should not be offered: {}",
text
);
}
}