use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::storage::Store;
pub const DOWNLOAD_DIR_KEY: &str = "download_dir";
pub fn platform_download_dir() -> PathBuf {
if let Some(dirs) = directories::UserDirs::new() {
if let Some(downloads) = dirs.download_dir() {
return downloads.to_path_buf();
}
return dirs.home_dir().join("Downloads");
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn tildify(path: &Path) -> String {
if let Some(dirs) = directories::UserDirs::new() {
if let Ok(rest) = path.strip_prefix(dirs.home_dir()) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
pub fn saved_download_dir(store: &Store) -> Result<Option<PathBuf>> {
Ok(store
.get_meta(DOWNLOAD_DIR_KEY)?
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from))
}
pub fn save_download_dir(store: &Store, dir: &Path) -> Result<()> {
store.set_meta(DOWNLOAD_DIR_KEY, &dir.to_string_lossy())
}
pub fn normalise_dir(input: &str) -> Result<PathBuf> {
let expanded = crate::naming::expand_tilde(input.trim());
let absolute = if expanded.is_absolute() {
expanded
} else {
std::env::current_dir()
.context("cannot determine the current directory")?
.join(expanded)
};
if absolute.exists() && !absolute.is_dir() {
anyhow::bail!("{} exists but is not a directory", absolute.display());
}
Ok(absolute)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirSource {
Flag,
Saved,
Prompted,
PlatformDefault,
}
#[derive(Debug, Clone)]
pub struct ResolvedDir {
pub path: PathBuf,
pub source: DirSource,
}
pub fn resolve_download_dir<P>(store: &Store, flag: Option<&str>, prompt: P) -> Result<ResolvedDir>
where
P: FnOnce(&Path) -> Result<Option<PathBuf>>,
{
if let Some(dir) = flag {
return Ok(ResolvedDir {
path: normalise_dir(dir)?,
source: DirSource::Flag,
});
}
if let Some(saved) = saved_download_dir(store)? {
return Ok(ResolvedDir {
path: saved,
source: DirSource::Saved,
});
}
let default = platform_download_dir();
match prompt(&default)? {
Some(chosen) => {
save_download_dir(store, &chosen)?;
Ok(ResolvedDir {
path: chosen,
source: DirSource::Prompted,
})
}
None => Ok(ResolvedDir {
path: default,
source: DirSource::PlatformDefault,
}),
}
}
pub fn can_prompt(machine_output: bool) -> bool {
!machine_output && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
pub fn prompt_for_download_dir(default: &Path, machine_output: bool) -> Result<Option<PathBuf>> {
if !can_prompt(machine_output) {
return Ok(None);
}
let mut stdin = std::io::stdin().lock();
ask(default, &mut stdin, &mut std::io::stderr()).map(Some)
}
pub fn ask<R: BufRead, W: Write>(default: &Path, input: &mut R, output: &mut W) -> Result<PathBuf> {
writeln!(output, "Where should rget save downloads?")?;
for attempt in 0..3 {
write!(output, " Folder [{}]: ", tildify(default))?;
output.flush()?;
let mut line = String::new();
if input.read_line(&mut line)? == 0 {
break;
}
let answer = line.trim();
let candidate = if answer.is_empty() {
default.to_path_buf()
} else {
match normalise_dir(answer) {
Ok(path) => path,
Err(err) => {
writeln!(output, " {err}")?;
if attempt < 2 {
continue;
}
default.to_path_buf()
}
}
};
writeln!(output, " Saving downloads to {}", tildify(&candidate))?;
writeln!(output, " Change it later with `rget config --dir <path>`.")?;
writeln!(output)?;
return Ok(candidate);
}
Ok(default.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn store() -> Store {
Store::open_in_memory().unwrap()
}
#[test]
fn platform_default_is_absolute() {
let dir = platform_download_dir();
assert!(dir.is_absolute() || dir == Path::new("."));
}
#[test]
fn tildify_shortens_the_home_path() {
let home = directories::UserDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
assert_eq!(tildify(&home.join("Downloads")), "~/Downloads");
assert_eq!(tildify(&home), "~");
assert_eq!(tildify(Path::new("/var/tmp")), "/var/tmp");
}
#[test]
fn flag_beats_everything() {
let s = store();
save_download_dir(&s, Path::new("/tmp/saved")).unwrap();
let resolved = resolve_download_dir(&s, Some("/tmp/flag"), |_| {
panic!("must not prompt when --dir was given")
})
.unwrap();
assert_eq!(resolved.path, PathBuf::from("/tmp/flag"));
assert_eq!(resolved.source, DirSource::Flag);
}
#[test]
fn saved_setting_beats_the_platform_default() {
let s = store();
save_download_dir(&s, Path::new("/tmp/saved")).unwrap();
let resolved =
resolve_download_dir(&s, None, |_| panic!("must only prompt once, ever")).unwrap();
assert_eq!(resolved.path, PathBuf::from("/tmp/saved"));
assert_eq!(resolved.source, DirSource::Saved);
}
#[test]
fn first_run_prompts_and_remembers_the_answer() {
let s = store();
let resolved =
resolve_download_dir(&s, None, |_| Ok(Some(PathBuf::from("/tmp/chosen")))).unwrap();
assert_eq!(resolved.path, PathBuf::from("/tmp/chosen"));
assert_eq!(resolved.source, DirSource::Prompted);
let again = resolve_download_dir(&s, None, |_| panic!("asked twice")).unwrap();
assert_eq!(again.path, PathBuf::from("/tmp/chosen"));
assert_eq!(again.source, DirSource::Saved);
}
#[test]
fn non_interactive_falls_back_without_saving() {
let s = store();
let resolved = resolve_download_dir(&s, None, |_| Ok(None)).unwrap();
assert_eq!(resolved.path, platform_download_dir());
assert_eq!(resolved.source, DirSource::PlatformDefault);
assert_eq!(saved_download_dir(&s).unwrap(), None);
}
#[test]
fn empty_answer_accepts_the_default() {
let default = PathBuf::from("/tmp/default-dl");
let mut input = Cursor::new(b"\n".to_vec());
let mut output = Vec::new();
let chosen = ask(&default, &mut input, &mut output).unwrap();
assert_eq!(chosen, default);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Where should rget save downloads?"), "{text}");
assert!(text.contains("/tmp/default-dl"), "{text}");
assert!(text.contains("rget config --dir"), "{text}");
}
#[test]
fn typed_answer_is_expanded_and_absolutised() {
let mut input = Cursor::new(b" ~/Elsewhere \n".to_vec());
let mut output = Vec::new();
let chosen = ask(Path::new("/tmp/default-dl"), &mut input, &mut output).unwrap();
let home = directories::UserDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
assert_eq!(chosen, home.join("Elsewhere"));
assert!(chosen.is_absolute());
}
#[test]
fn eof_takes_the_default_rather_than_looping() {
let default = PathBuf::from("/tmp/default-dl");
let mut input = Cursor::new(Vec::new());
let mut output = Vec::new();
assert_eq!(ask(&default, &mut input, &mut output).unwrap(), default);
}
#[test]
fn a_bad_answer_is_re_asked_then_defaulted() {
let dir = std::env::temp_dir().join(format!("rget-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let default = PathBuf::from("/tmp/default-dl");
let input_text = format!(
"{}\n{}\n{}\n",
file.display(),
file.display(),
file.display()
);
let mut input = Cursor::new(input_text.into_bytes());
let mut output = Vec::new();
let chosen = ask(&default, &mut input, &mut output).unwrap();
assert_eq!(chosen, default, "should give up gracefully, not loop");
let text = String::from_utf8(output).unwrap();
assert!(text.contains("not a directory"), "{text}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_good_answer_after_a_bad_one_is_accepted() {
let dir = std::env::temp_dir().join(format!("rget-cfg2-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let input_text = format!("{}\n/tmp/good-choice\n", file.display());
let mut input = Cursor::new(input_text.into_bytes());
let mut output = Vec::new();
let chosen = ask(Path::new("/tmp/default-dl"), &mut input, &mut output).unwrap();
assert_eq!(chosen, PathBuf::from("/tmp/good-choice"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn normalise_rejects_a_file_and_absolutises_relative_paths() {
assert!(normalise_dir("/tmp").is_ok());
let relative = normalise_dir("some-subdir").unwrap();
assert!(relative.is_absolute());
assert!(relative.ends_with("some-subdir"));
let dir = std::env::temp_dir().join(format!("rget-cfg3-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("f");
std::fs::write(&file, b"x").unwrap();
assert!(normalise_dir(&file.to_string_lossy()).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn machine_output_never_prompts() {
assert!(!can_prompt(true));
}
}