Skip to main content

yui/
mount.rs

1//! Resolve `[[mount.entry]]` definitions: render `src` and `dst` via
2//! Tera, evaluate `when`, drop disabled entries.
3
4use camino::{Utf8Path, Utf8PathBuf};
5use tera::Context;
6
7use crate::Result;
8use crate::config::{MountEntry, MountStrategy};
9use crate::paths;
10use crate::template::{self, Engine};
11
12/// A mount entry after Tera rendering, tilde expansion, and
13/// `when`-filtering. Both `src` and `dst` are absolute paths.
14#[derive(Debug, Clone)]
15pub struct ResolvedMount {
16    /// Absolute path to the source subtree. For relative inputs this
17    /// is `<source>/<entry.src>`; absolute / `~`-relative inputs land
18    /// where the user pointed. Letting `src` escape the dotfiles repo
19    /// is intentional — it's how a separate private clone (e.g.
20    /// `~/.dotfiles-private/home`) participates as a mount without
21    /// having to live under `$DOTFILES`.
22    pub src: Utf8PathBuf,
23    pub dst: Utf8PathBuf,
24    pub strategy: MountStrategy,
25}
26
27pub fn resolve(
28    source: &Utf8Path,
29    entries: &[MountEntry],
30    default_strategy: MountStrategy,
31    engine: &mut Engine,
32    ctx: &Context,
33) -> Result<Vec<ResolvedMount>> {
34    let mut out = Vec::with_capacity(entries.len());
35    for e in entries {
36        if let Some(when) = &e.when {
37            // `template::eval_truthy` accepts both bare (`yui.os == 'linux'`)
38            // and pre-wrapped (`{{ … }}`) forms — same convention used by
39            // marker links and render rules. Without it, a bare expression
40            // would be silently filtered out (the literal expression string
41            // doesn't equal "true" / "1" so the row drops). The README and
42            // `init` skeleton recommend bare form, so this MUST agree.
43            if !template::eval_truthy(when, engine, ctx)? {
44                continue;
45            }
46        }
47        let src_str = engine.render(e.src.as_str(), ctx)?;
48        let src = paths::resolve_mount_src(source, src_str.trim());
49        let dst_str = engine.render(&e.dst, ctx)?;
50        let dst = paths::expand_tilde(dst_str.trim());
51        out.push(ResolvedMount {
52            src,
53            dst,
54            strategy: e.strategy.unwrap_or(default_strategy),
55        });
56    }
57    Ok(out)
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::template;
64    use crate::vars::YuiVars;
65
66    fn vars() -> YuiVars {
67        YuiVars {
68            os: "linux".into(),
69            arch: "x86_64".into(),
70            host: "test".into(),
71            user: "u".into(),
72            source: "/dotfiles".into(),
73        }
74    }
75
76    fn source() -> Utf8PathBuf {
77        Utf8PathBuf::from("/dotfiles")
78    }
79
80    #[test]
81    fn renders_dst_and_filters_when_false() {
82        let entries = vec![
83            MountEntry {
84                src: "home".into(),
85                dst: "/{{ yui.os }}/u".into(),
86                when: None,
87                strategy: None,
88            },
89            MountEntry {
90                src: "appdata".into(),
91                dst: "/appdata".into(),
92                when: Some("{{ yui.os == 'windows' }}".into()),
93                strategy: None,
94            },
95        ];
96        let mut e = Engine::new();
97        let ctx = template::config_context(&vars());
98        let s = source();
99        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
100        assert_eq!(resolved.len(), 1);
101        // Relative `src` is resolved against the source root.
102        assert_eq!(resolved[0].src, Utf8PathBuf::from("/dotfiles/home"));
103        assert_eq!(resolved[0].dst, Utf8PathBuf::from("/linux/u"));
104        assert_eq!(resolved[0].strategy, MountStrategy::Marker);
105    }
106
107    #[test]
108    fn when_true_keeps_entry() {
109        let entries = vec![MountEntry {
110            src: "home".into(),
111            dst: "/h".into(),
112            when: Some("{{ yui.os == 'linux' }}".into()),
113            strategy: None,
114        }];
115        let mut e = Engine::new();
116        let ctx = template::config_context(&vars());
117        let s = source();
118        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
119        assert_eq!(resolved.len(), 1);
120    }
121
122    #[test]
123    fn bare_when_form_works() {
124        // Regression: README and `init` skeleton recommend bare-form `when`,
125        // so this MUST resolve via `template::eval_truthy`. Earlier this
126        // function used a direct `engine.render(when)` which only handled
127        // the wrapped form — caught in PR #12 review (gemini-code-assist).
128        let entries = vec![MountEntry {
129            src: "home".into(),
130            dst: "/h".into(),
131            when: Some("yui.os == 'linux'".into()),
132            strategy: None,
133        }];
134        let mut e = Engine::new();
135        let ctx = template::config_context(&vars());
136        let s = source();
137        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
138        assert_eq!(resolved.len(), 1);
139    }
140
141    #[test]
142    fn bare_when_form_filters_when_false() {
143        let entries = vec![MountEntry {
144            src: "home".into(),
145            dst: "/h".into(),
146            when: Some("yui.os == 'no-such-os'".into()),
147            strategy: None,
148        }];
149        let mut e = Engine::new();
150        let ctx = template::config_context(&vars());
151        let s = source();
152        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
153        assert!(resolved.is_empty());
154    }
155
156    #[test]
157    fn per_entry_strategy_overrides_default() {
158        let entries = vec![MountEntry {
159            src: "home".into(),
160            dst: "/h".into(),
161            when: None,
162            strategy: Some(MountStrategy::PerFile),
163        }];
164        let mut e = Engine::new();
165        let ctx = template::config_context(&vars());
166        let s = source();
167        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
168        assert_eq!(resolved[0].strategy, MountStrategy::PerFile);
169    }
170
171    /// Absolute `src` lets a separate (e.g. private) clone outside
172    /// `$DOTFILES` participate as a mount without symlinking. The
173    /// resolver returns the absolute path verbatim.
174    #[test]
175    fn absolute_src_is_returned_verbatim() {
176        let entries = vec![MountEntry {
177            src: "/abs/private/home".into(),
178            dst: "/h".into(),
179            when: None,
180            strategy: None,
181        }];
182        let mut e = Engine::new();
183        let ctx = template::config_context(&vars());
184        let s = source();
185        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
186        assert_eq!(resolved[0].src, Utf8PathBuf::from("/abs/private/home"));
187    }
188
189    /// Tera renders against the same context the call site builds
190    /// (yui.* + vars.*). Letting `src` use `{{ yui.host }}` etc.
191    /// makes per-machine source dirs trivial.
192    #[test]
193    fn src_renders_via_tera() {
194        let entries = vec![MountEntry {
195            src: "private/{{ yui.host }}/home".into(),
196            dst: "/h".into(),
197            when: None,
198            strategy: None,
199        }];
200        let mut e = Engine::new();
201        let ctx = template::config_context(&vars());
202        let s = source();
203        let resolved = resolve(&s, &entries, MountStrategy::Marker, &mut e, &ctx).unwrap();
204        // vars().host is "test"
205        assert_eq!(
206            resolved[0].src,
207            Utf8PathBuf::from("/dotfiles/private/test/home")
208        );
209    }
210}