Skip to main content

trail/config/
last_used.rs

1//! Remembers the config file last loaded via `--config`.
2//!
3//! Passing `--config <path>` records that path so a later bare `trail` picks
4//! the same configuration back up, instead of silently falling back to the
5//! built-in defaults. The path is stored in Trail's data directory alongside
6//! `bookmarks.toml` and `recent_dirs.toml`.
7//!
8//! # Why the source matters
9//!
10//! A config that fails to load is fatal when the user asked for it by name —
11//! they passed `--config`, so a silent fallback would hide a typo. The same
12//! failure must *not* be fatal for a remembered path: the user may have moved,
13//! deleted or broken that file long after the run that recorded it, and a bare
14//! `trail` refusing to start would leave them with no obvious way back. The
15//! [`ConfigSource`] returned by [`resolve`] carries that distinction so the
16//! caller applies the right policy instead of inferring it.
17
18use std::io;
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23/// File name, inside Trail's data directory, holding the remembered path.
24pub const STATE_FILE_NAME: &str = "last_config.toml";
25
26/// On-disk shape of [`STATE_FILE_NAME`].
27#[derive(Debug, Serialize, Deserialize)]
28struct StoredConfigPath {
29    /// Absolute path to the config file last loaded via `--config`.
30    path: PathBuf,
31}
32
33/// Where this run's configuration comes from.
34///
35/// The variant determines how a load failure is handled — see the module
36/// documentation.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ConfigSource {
39    /// No config file: use the built-in defaults.
40    Defaults,
41
42    /// A path the user named on this run via `--config`.
43    ///
44    /// A load failure here is fatal: the user asked for this file by name.
45    Explicit(PathBuf),
46
47    /// A path recalled from an earlier run.
48    ///
49    /// A load failure here degrades to the built-in defaults, because the file
50    /// may have changed or disappeared since it was recorded.
51    Remembered(PathBuf),
52}
53
54/// Decides which configuration this run should load.
55///
56/// Precedence: an `explicit` `--config` path wins; otherwise a `remembered`
57/// path is used unless `ignore_remembered` (`--no-config`) is set; otherwise
58/// the built-in defaults.
59///
60/// Pure: performs no I/O, so the precedence rules are testable on their own.
61pub fn resolve(
62    explicit: Option<PathBuf>,
63    remembered: Option<PathBuf>,
64    ignore_remembered: bool,
65) -> ConfigSource {
66    if let Some(path) = explicit {
67        return ConfigSource::Explicit(path);
68    }
69    if ignore_remembered {
70        return ConfigSource::Defaults;
71    }
72    match remembered {
73        Some(path) => ConfigSource::Remembered(path),
74        None => ConfigSource::Defaults,
75    }
76}
77
78/// Reads the remembered config path from `state_file`.
79///
80/// Returns `None` when the file is absent, unreadable, or malformed — a
81/// corrupt state file must never stop Trail from starting, so every failure
82/// degrades to "nothing remembered" and is logged at `debug` level.
83pub fn remembered(state_file: &Path) -> Option<PathBuf> {
84    let content = match std::fs::read_to_string(state_file) {
85        Ok(c) => c,
86        Err(e) => {
87            // Absent is the normal case on a first run, so this is not a warning.
88            tracing::debug!(?state_file, "no remembered config: {e}");
89            return None;
90        }
91    };
92
93    match toml::from_str::<StoredConfigPath>(&content) {
94        Ok(stored) => Some(stored.path),
95        Err(e) => {
96            tracing::debug!(?state_file, "ignoring malformed remembered config: {e}");
97            None
98        }
99    }
100}
101
102/// Records `config_path` in `state_file` as the config to reuse next run.
103///
104/// The path is made absolute first: Trail is normally launched from whichever
105/// directory the user happens to be in, so a relative `--config trail.toml`
106/// would not resolve on a later run from elsewhere.
107///
108/// Call this only after the config has loaded successfully, so a file that
109/// cannot be parsed is never recorded as the one to reuse.
110///
111/// # Errors
112///
113/// Returns an [`io::Error`] if the state file cannot be serialized or written.
114/// Callers should treat that as non-fatal — failing to remember is an
115/// inconvenience, not a reason to abort the session.
116pub fn remember(state_file: &Path, config_path: &Path) -> io::Result<()> {
117    // absolute() does not resolve symlinks and does not add a Windows verbatim
118    // (\\?\) prefix, so the stored path stays readable if a human opens the
119    // state file. Fall back to the path as given if it cannot be absolutized.
120    let absolute = std::path::absolute(config_path).unwrap_or_else(|e| {
121        tracing::debug!(?config_path, "could not absolutize config path: {e}");
122        config_path.to_owned()
123    });
124
125    let stored = StoredConfigPath { path: absolute };
126    let content = toml::to_string_pretty(&stored)
127        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
128    std::fs::write(state_file, content)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn resolve_prefers_explicit_over_remembered() {
137        let source = resolve(
138            Some(PathBuf::from("/explicit.toml")),
139            Some(PathBuf::from("/remembered.toml")),
140            false,
141        );
142        assert_eq!(
143            source,
144            ConfigSource::Explicit(PathBuf::from("/explicit.toml"))
145        );
146    }
147
148    #[test]
149    fn resolve_uses_remembered_when_no_explicit() {
150        let source = resolve(None, Some(PathBuf::from("/remembered.toml")), false);
151        assert_eq!(
152            source,
153            ConfigSource::Remembered(PathBuf::from("/remembered.toml"))
154        );
155    }
156
157    #[test]
158    fn resolve_ignores_remembered_when_bypassed() {
159        let source = resolve(None, Some(PathBuf::from("/remembered.toml")), true);
160        assert_eq!(source, ConfigSource::Defaults);
161    }
162
163    #[test]
164    fn resolve_falls_back_to_defaults() {
165        assert_eq!(resolve(None, None, false), ConfigSource::Defaults);
166    }
167
168    #[test]
169    fn explicit_still_wins_when_remembered_is_bypassed() {
170        // --config and --no-config conflict at the CLI layer, but `resolve`
171        // stays total rather than relying on that guarantee.
172        let source = resolve(Some(PathBuf::from("/explicit.toml")), None, true);
173        assert_eq!(
174            source,
175            ConfigSource::Explicit(PathBuf::from("/explicit.toml"))
176        );
177    }
178
179    #[test]
180    fn remember_then_recall_round_trips() {
181        let dir = tempfile::tempdir().unwrap();
182        let state = dir.path().join(STATE_FILE_NAME);
183        let config = dir.path().join("trail.toml");
184        std::fs::write(&config, "").unwrap();
185
186        remember(&state, &config).unwrap();
187        assert_eq!(remembered(&state), Some(config));
188    }
189
190    #[test]
191    fn remember_stores_an_absolute_path() {
192        let dir = tempfile::tempdir().unwrap();
193        let state = dir.path().join(STATE_FILE_NAME);
194
195        remember(&state, Path::new("trail.toml")).unwrap();
196
197        let recalled = remembered(&state).expect("a path should be remembered");
198        assert!(
199            recalled.is_absolute(),
200            "a relative --config path must be stored absolute so it still \
201             resolves when Trail is next launched from another directory; got {recalled:?}"
202        );
203        assert!(recalled.ends_with("trail.toml"));
204    }
205
206    #[test]
207    fn recall_returns_none_when_state_file_is_absent() {
208        let dir = tempfile::tempdir().unwrap();
209        assert_eq!(remembered(&dir.path().join("does-not-exist.toml")), None);
210    }
211
212    #[test]
213    fn recall_returns_none_for_a_malformed_state_file() {
214        let dir = tempfile::tempdir().unwrap();
215        let state = dir.path().join(STATE_FILE_NAME);
216        std::fs::write(&state, "this is not valid toml {{{").unwrap();
217
218        // A corrupt state file must degrade to "nothing remembered" rather
219        // than stopping Trail from starting.
220        assert_eq!(remembered(&state), None);
221    }
222
223    #[test]
224    fn recall_returns_none_when_key_is_missing() {
225        let dir = tempfile::tempdir().unwrap();
226        let state = dir.path().join(STATE_FILE_NAME);
227        std::fs::write(&state, "unrelated = \"value\"\n").unwrap();
228
229        assert_eq!(remembered(&state), None);
230    }
231
232    #[test]
233    fn remember_overwrites_a_previous_path() {
234        let dir = tempfile::tempdir().unwrap();
235        let state = dir.path().join(STATE_FILE_NAME);
236        let first = dir.path().join("first.toml");
237        let second = dir.path().join("second.toml");
238
239        remember(&state, &first).unwrap();
240        remember(&state, &second).unwrap();
241
242        assert_eq!(remembered(&state), Some(second));
243    }
244}