1use std::io::{BufRead, IsTerminal, Write};
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17use crate::storage::Store;
18
19pub const DOWNLOAD_DIR_KEY: &str = "download_dir";
21
22pub fn platform_download_dir() -> PathBuf {
29 if let Some(dirs) = directories::UserDirs::new() {
30 if let Some(downloads) = dirs.download_dir() {
31 return downloads.to_path_buf();
32 }
33 return dirs.home_dir().join("Downloads");
34 }
35 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
38}
39
40pub fn tildify(path: &Path) -> String {
43 if let Some(dirs) = directories::UserDirs::new() {
44 if let Ok(rest) = path.strip_prefix(dirs.home_dir()) {
45 if rest.as_os_str().is_empty() {
46 return "~".to_string();
47 }
48 return format!("~/{}", rest.display());
49 }
50 }
51 path.display().to_string()
52}
53
54pub fn saved_download_dir(store: &Store) -> Result<Option<PathBuf>> {
56 Ok(store
57 .get_meta(DOWNLOAD_DIR_KEY)?
58 .filter(|v| !v.trim().is_empty())
59 .map(PathBuf::from))
60}
61
62pub fn save_download_dir(store: &Store, dir: &Path) -> Result<()> {
63 store.set_meta(DOWNLOAD_DIR_KEY, &dir.to_string_lossy())
64}
65
66pub fn normalise_dir(input: &str) -> Result<PathBuf> {
69 let expanded = crate::naming::expand_tilde(input.trim());
70 let absolute = if expanded.is_absolute() {
71 expanded
72 } else {
73 std::env::current_dir()
74 .context("cannot determine the current directory")?
75 .join(expanded)
76 };
77
78 if absolute.exists() && !absolute.is_dir() {
79 anyhow::bail!("{} exists but is not a directory", absolute.display());
80 }
81 Ok(absolute)
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum DirSource {
88 Flag,
90 Saved,
92 Prompted,
94 PlatformDefault,
96}
97
98#[derive(Debug, Clone)]
99pub struct ResolvedDir {
100 pub path: PathBuf,
101 pub source: DirSource,
102}
103
104pub fn resolve_download_dir<P>(store: &Store, flag: Option<&str>, prompt: P) -> Result<ResolvedDir>
111where
112 P: FnOnce(&Path) -> Result<Option<PathBuf>>,
113{
114 if let Some(dir) = flag {
115 return Ok(ResolvedDir {
116 path: normalise_dir(dir)?,
117 source: DirSource::Flag,
118 });
119 }
120
121 if let Some(saved) = saved_download_dir(store)? {
122 return Ok(ResolvedDir {
123 path: saved,
124 source: DirSource::Saved,
125 });
126 }
127
128 let default = platform_download_dir();
129 match prompt(&default)? {
130 Some(chosen) => {
131 save_download_dir(store, &chosen)?;
132 Ok(ResolvedDir {
133 path: chosen,
134 source: DirSource::Prompted,
135 })
136 }
137 None => Ok(ResolvedDir {
138 path: default,
139 source: DirSource::PlatformDefault,
140 }),
141 }
142}
143
144pub fn can_prompt(machine_output: bool) -> bool {
150 !machine_output && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
151}
152
153pub fn prompt_for_download_dir(default: &Path, machine_output: bool) -> Result<Option<PathBuf>> {
155 if !can_prompt(machine_output) {
156 return Ok(None);
157 }
158 let mut stdin = std::io::stdin().lock();
159 ask(default, &mut stdin, &mut std::io::stderr()).map(Some)
160}
161
162pub fn ask<R: BufRead, W: Write>(default: &Path, input: &mut R, output: &mut W) -> Result<PathBuf> {
164 writeln!(output, "Where should rget save downloads?")?;
165
166 for attempt in 0..3 {
169 write!(output, " Folder [{}]: ", tildify(default))?;
170 output.flush()?;
171
172 let mut line = String::new();
173 if input.read_line(&mut line)? == 0 {
174 break;
176 }
177 let answer = line.trim();
178 let candidate = if answer.is_empty() {
179 default.to_path_buf()
180 } else {
181 match normalise_dir(answer) {
182 Ok(path) => path,
183 Err(err) => {
184 writeln!(output, " {err}")?;
185 if attempt < 2 {
186 continue;
187 }
188 default.to_path_buf()
189 }
190 }
191 };
192
193 writeln!(output, " Saving downloads to {}", tildify(&candidate))?;
194 writeln!(output, " Change it later with `rget config --dir <path>`.")?;
195 writeln!(output)?;
196 return Ok(candidate);
197 }
198
199 Ok(default.to_path_buf())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::io::Cursor;
206
207 fn store() -> Store {
208 Store::open_in_memory().unwrap()
209 }
210
211 #[test]
212 fn platform_default_is_absolute() {
213 let dir = platform_download_dir();
214 assert!(dir.is_absolute() || dir == Path::new("."));
215 }
216
217 #[test]
218 fn tildify_shortens_the_home_path() {
219 let home = directories::UserDirs::new()
220 .unwrap()
221 .home_dir()
222 .to_path_buf();
223 assert_eq!(tildify(&home.join("Downloads")), "~/Downloads");
224 assert_eq!(tildify(&home), "~");
225 assert_eq!(tildify(Path::new("/var/tmp")), "/var/tmp");
226 }
227
228 #[test]
229 fn flag_beats_everything() {
230 let s = store();
231 save_download_dir(&s, Path::new("/tmp/saved")).unwrap();
232 let resolved = resolve_download_dir(&s, Some("/tmp/flag"), |_| {
233 panic!("must not prompt when --dir was given")
234 })
235 .unwrap();
236 assert_eq!(resolved.path, PathBuf::from("/tmp/flag"));
237 assert_eq!(resolved.source, DirSource::Flag);
238 }
239
240 #[test]
241 fn saved_setting_beats_the_platform_default() {
242 let s = store();
243 save_download_dir(&s, Path::new("/tmp/saved")).unwrap();
244 let resolved =
245 resolve_download_dir(&s, None, |_| panic!("must only prompt once, ever")).unwrap();
246 assert_eq!(resolved.path, PathBuf::from("/tmp/saved"));
247 assert_eq!(resolved.source, DirSource::Saved);
248 }
249
250 #[test]
251 fn first_run_prompts_and_remembers_the_answer() {
252 let s = store();
253 let resolved =
254 resolve_download_dir(&s, None, |_| Ok(Some(PathBuf::from("/tmp/chosen")))).unwrap();
255 assert_eq!(resolved.path, PathBuf::from("/tmp/chosen"));
256 assert_eq!(resolved.source, DirSource::Prompted);
257
258 let again = resolve_download_dir(&s, None, |_| panic!("asked twice")).unwrap();
260 assert_eq!(again.path, PathBuf::from("/tmp/chosen"));
261 assert_eq!(again.source, DirSource::Saved);
262 }
263
264 #[test]
265 fn non_interactive_falls_back_without_saving() {
266 let s = store();
267 let resolved = resolve_download_dir(&s, None, |_| Ok(None)).unwrap();
268 assert_eq!(resolved.path, platform_download_dir());
269 assert_eq!(resolved.source, DirSource::PlatformDefault);
270
271 assert_eq!(saved_download_dir(&s).unwrap(), None);
273 }
274
275 #[test]
276 fn empty_answer_accepts_the_default() {
277 let default = PathBuf::from("/tmp/default-dl");
278 let mut input = Cursor::new(b"\n".to_vec());
279 let mut output = Vec::new();
280 let chosen = ask(&default, &mut input, &mut output).unwrap();
281 assert_eq!(chosen, default);
282
283 let text = String::from_utf8(output).unwrap();
284 assert!(text.contains("Where should rget save downloads?"), "{text}");
285 assert!(text.contains("/tmp/default-dl"), "{text}");
286 assert!(text.contains("rget config --dir"), "{text}");
287 }
288
289 #[test]
290 fn typed_answer_is_expanded_and_absolutised() {
291 let mut input = Cursor::new(b" ~/Elsewhere \n".to_vec());
292 let mut output = Vec::new();
293 let chosen = ask(Path::new("/tmp/default-dl"), &mut input, &mut output).unwrap();
294 let home = directories::UserDirs::new()
295 .unwrap()
296 .home_dir()
297 .to_path_buf();
298 assert_eq!(chosen, home.join("Elsewhere"));
299 assert!(chosen.is_absolute());
300 }
301
302 #[test]
303 fn eof_takes_the_default_rather_than_looping() {
304 let default = PathBuf::from("/tmp/default-dl");
305 let mut input = Cursor::new(Vec::new());
306 let mut output = Vec::new();
307 assert_eq!(ask(&default, &mut input, &mut output).unwrap(), default);
308 }
309
310 #[test]
311 fn a_bad_answer_is_re_asked_then_defaulted() {
312 let dir = std::env::temp_dir().join(format!("rget-cfg-{}", std::process::id()));
314 std::fs::create_dir_all(&dir).unwrap();
315 let file = dir.join("not-a-dir");
316 std::fs::write(&file, b"x").unwrap();
317
318 let default = PathBuf::from("/tmp/default-dl");
319 let input_text = format!(
320 "{}\n{}\n{}\n",
321 file.display(),
322 file.display(),
323 file.display()
324 );
325 let mut input = Cursor::new(input_text.into_bytes());
326 let mut output = Vec::new();
327 let chosen = ask(&default, &mut input, &mut output).unwrap();
328
329 assert_eq!(chosen, default, "should give up gracefully, not loop");
330 let text = String::from_utf8(output).unwrap();
331 assert!(text.contains("not a directory"), "{text}");
332
333 std::fs::remove_dir_all(&dir).ok();
334 }
335
336 #[test]
337 fn a_good_answer_after_a_bad_one_is_accepted() {
338 let dir = std::env::temp_dir().join(format!("rget-cfg2-{}", std::process::id()));
339 std::fs::create_dir_all(&dir).unwrap();
340 let file = dir.join("not-a-dir");
341 std::fs::write(&file, b"x").unwrap();
342
343 let input_text = format!("{}\n/tmp/good-choice\n", file.display());
344 let mut input = Cursor::new(input_text.into_bytes());
345 let mut output = Vec::new();
346 let chosen = ask(Path::new("/tmp/default-dl"), &mut input, &mut output).unwrap();
347 assert_eq!(chosen, PathBuf::from("/tmp/good-choice"));
348
349 std::fs::remove_dir_all(&dir).ok();
350 }
351
352 #[test]
353 fn normalise_rejects_a_file_and_absolutises_relative_paths() {
354 assert!(normalise_dir("/tmp").is_ok());
355 let relative = normalise_dir("some-subdir").unwrap();
356 assert!(relative.is_absolute());
357 assert!(relative.ends_with("some-subdir"));
358
359 let dir = std::env::temp_dir().join(format!("rget-cfg3-{}", std::process::id()));
360 std::fs::create_dir_all(&dir).unwrap();
361 let file = dir.join("f");
362 std::fs::write(&file, b"x").unwrap();
363 assert!(normalise_dir(&file.to_string_lossy()).is_err());
364 std::fs::remove_dir_all(&dir).ok();
365 }
366
367 #[test]
368 fn machine_output_never_prompts() {
369 assert!(!can_prompt(true));
371 }
372}