Skip to main content

rget/
config.rs

1//! User settings, and the first-run download-folder prompt.
2//!
3//! Settings live in the `meta` table of the same SQLite database as the
4//! downloads, so there is one file to back up, move or delete — no separate
5//! config file to drift out of sync with the download state.
6//!
7//! The prompt has one hard rule: **it must never block a script.** It is shown
8//! only when both stdin and stderr are terminals and no machine-output flag was
9//! given. Everywhere else the platform's Downloads folder is used silently, so
10//! `rget URL` behaves identically in a terminal and in a pipeline.
11
12use std::io::{BufRead, IsTerminal, Write};
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17use crate::storage::Store;
18
19/// Settings key for the folder downloads land in when no `--dir` is given.
20pub const DOWNLOAD_DIR_KEY: &str = "download_dir";
21
22/// The platform's Downloads folder.
23///
24/// On macOS that is `~/Downloads`. On Linux it is whatever `XDG_DOWNLOAD_DIR`
25/// says in `~/.config/user-dirs.dirs` — which is localised, so hardcoding
26/// "Downloads" would put files in the wrong place for anyone not using English.
27/// Only if that lookup fails do we guess `~/Downloads`.
28pub 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    // No home directory at all (a daemon, a stripped container): the working
36    // directory is the only sane answer.
37    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
38}
39
40/// Render a path with `~` for display, so the prompt reads the way a person
41/// would write it.
42pub 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
54/// The saved default download folder, if the user has ever chosen one.
55pub 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
66/// Turn user input into an absolute, usable directory: expand `~`, resolve
67/// relative paths against the working directory, and create it if needed.
68pub 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/// How the effective download folder was decided — reported under `--verbose`
85/// and asserted in tests.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum DirSource {
88    /// `--dir` on the command line.
89    Flag,
90    /// Previously saved by the user.
91    Saved,
92    /// Chosen at the first-run prompt just now.
93    Prompted,
94    /// Platform default, used without asking because we could not prompt.
95    PlatformDefault,
96}
97
98#[derive(Debug, Clone)]
99pub struct ResolvedDir {
100    pub path: PathBuf,
101    pub source: DirSource,
102}
103
104/// Decide where this download should go.
105///
106/// Precedence: `--dir` beats the saved setting, which beats the platform
107/// default. `prompt` returns `Ok(None)` when asking is not possible, in which
108/// case we fall back silently and — importantly — save nothing, so the question
109/// is still asked the next time the user is at a terminal.
110pub 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
144/// May we interrupt the user with a question?
145///
146/// Both stdin and stderr must be terminals: stdin because we need an answer,
147/// stderr because that is where the question appears. `--json` and `--quiet`
148/// mean a script is driving, so we stay silent regardless.
149pub fn can_prompt(machine_output: bool) -> bool {
150    !machine_output && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
151}
152
153/// The first-run prompt. Returns `None` if we must not ask.
154pub 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
162/// The prompt itself, with I/O injected so it can be tested without a terminal.
163pub 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    // Three tries, then take the default: an unanswerable prompt must not turn
167    // into an infinite loop.
168    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            // EOF — stdin closed under us.
175            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        // Second run must not ask again.
259        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        // Nothing was persisted, so a later interactive run still gets to ask.
272        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        // A path that exists but is a file, not a directory.
313        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        // Regardless of terminal state, --json / --quiet must not ask.
370        assert!(!can_prompt(true));
371    }
372}