Skip to main content

yui/
marker.rs

1//! `.yuilink` marker file detection + parsing.
2//!
3//! Two forms are accepted:
4//!   - **empty file** → "junction this dir at the parent mount's dst"
5//!     (the original presence-only marker semantics)
6//!   - **TOML with `[[link]]` entries** → declare explicit links from this
7//!     directory. Each entry produces one link (after `when` filter).
8//!
9//! ```toml
10//! # $DOTFILES/home/.config/nvim/.yuilink
11//! [[link]]
12//! dst = "{{ env(name='HOME') }}/.config/nvim"
13//!
14//! [[link]]
15//! dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
16//! when = "yui.os == 'windows'"
17//! ```
18//!
19//! Each `[[link]]` may carry an optional `src = "<filename>"` that scopes
20//! the link to a specific file inside the marker's directory rather than
21//! the directory itself:
22//!
23//! ```toml
24//! # $DOTFILES/home/.config/powershell/.yuilink
25//! [[link]]
26//! src = "profile.ps1"
27//! dst = "{{ env(name='USERPROFILE') }}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
28//! when = "yui.os == 'windows'"
29//! ```
30//!
31//! Stacking semantics (v0.6+): a marker no longer stops the walker. The
32//! walker keeps descending past markers and aggregates link entries from
33//! every marker it encounters. A descendant marker therefore *adds*
34//! destinations on top of its ancestors rather than replacing them. Each
35//! entry's `dst` is still the source of truth — if you want the default
36//! `~/.config/nvim`-style placement, list it explicitly.
37//!
38//! Default-dst behaviour, two cases (kept distinct on purpose):
39//!
40//!   - **Empty / link-less marker** — the walker still emits the
41//!     dir-level link to the parent mount's natural dst (the original
42//!     "presence-only" behaviour).
43//!   - **Directory-scoped `[[link]]`** (no `src`) — fully defines the
44//!     directory's placement. The parent mount's natural dst is *not*
45//!     implied; only what's listed here is linked at this dir.
46//!   - **File-scoped `[[link]]`** (with `src = "<filename>"`) — applies
47//!     only to the named sibling file. It does *not* claim
48//!     directory-level coverage, so per-file defaults from the parent
49//!     mount still apply to the rest of the dir (and to the same file
50//!     too, in addition to the explicit dst).
51
52use camino::Utf8Path;
53use serde::Deserialize;
54
55use crate::{Error, Result};
56
57#[derive(Debug, Clone)]
58pub enum MarkerSpec {
59    /// Empty marker — link this dir using the parent mount's natural dst.
60    PassThrough,
61    /// Explicit links. Each entry maps the marker's directory (or a
62    /// specific file inside it via `src`) to a destination.
63    Explicit { links: Vec<MarkerLink> },
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct MarkerLink {
68    /// Optional file scope. When set, this entry links the file at
69    /// `<marker-dir>/<src>` to `dst` instead of the directory itself.
70    /// Must be a single component (no path separators) so it stays a
71    /// sibling file of the marker.
72    #[serde(default)]
73    pub src: Option<String>,
74    pub dst: String,
75    #[serde(default)]
76    pub when: Option<String>,
77}
78
79#[derive(Deserialize)]
80struct MarkerFile {
81    #[serde(default)]
82    link: Vec<MarkerLink>,
83}
84
85/// Read and parse a `.yuilink` from `dir`.
86///
87/// Returns:
88///   - `Ok(None)` — no marker file present
89///   - `Ok(Some(PassThrough))` — present and empty / whitespace-only / no `[[link]]`
90///   - `Ok(Some(Explicit { ... }))` — present with `[[link]]` entries
91///   - `Err(_)` — present but malformed TOML, or other IO error
92pub fn read_spec(dir: &Utf8Path, marker_filename: &str) -> Result<Option<MarkerSpec>> {
93    let path = dir.join(marker_filename);
94    let raw = match std::fs::read_to_string(&path) {
95        Ok(s) => s,
96        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
97        Err(e) => return Err(Error::Io(e)),
98    };
99    if raw.trim().is_empty() {
100        return Ok(Some(MarkerSpec::PassThrough));
101    }
102    let parsed: MarkerFile =
103        toml::from_str(&raw).map_err(|e| Error::Config(format!("parse {path}: {e}")))?;
104    if parsed.link.is_empty() {
105        return Ok(Some(MarkerSpec::PassThrough));
106    }
107    for link in &parsed.link {
108        if let Some(src) = &link.src {
109            // Reject anything that isn't a plain sibling file. `.` /
110            // `..` would point at the marker dir itself or its parent,
111            // and path separators would let the entry escape the dir
112            // entirely — neither matches the "single filename" promise.
113            if src.is_empty()
114                || src == "."
115                || src == ".."
116                || src.contains('/')
117                || src.contains('\\')
118            {
119                return Err(Error::Config(format!(
120                    "parse {path}: [[link]] src must be a single filename (no path separators or `.`/`..`), got {src:?}"
121                )));
122            }
123        }
124    }
125    Ok(Some(MarkerSpec::Explicit { links: parsed.link }))
126}
127
128/// Presence-only check: any `.yuilink` file (empty or with content) counts.
129/// Kept for callers that don't need the spec contents.
130pub fn is_marker_dir(dir: &Utf8Path, marker_filename: &str) -> bool {
131    dir.join(marker_filename).is_file()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use camino::Utf8PathBuf;
138    use tempfile::TempDir;
139
140    fn root(tmp: &TempDir) -> Utf8PathBuf {
141        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
142    }
143
144    #[test]
145    fn no_marker_returns_none() {
146        let tmp = TempDir::new().unwrap();
147        assert!(read_spec(&root(&tmp), ".yuilink").unwrap().is_none());
148    }
149
150    #[test]
151    fn empty_marker_is_passthrough() {
152        let tmp = TempDir::new().unwrap();
153        std::fs::write(tmp.path().join(".yuilink"), "").unwrap();
154        assert!(matches!(
155            read_spec(&root(&tmp), ".yuilink").unwrap(),
156            Some(MarkerSpec::PassThrough)
157        ));
158    }
159
160    #[test]
161    fn whitespace_only_marker_is_passthrough() {
162        let tmp = TempDir::new().unwrap();
163        std::fs::write(tmp.path().join(".yuilink"), "  \n\n").unwrap();
164        assert!(matches!(
165            read_spec(&root(&tmp), ".yuilink").unwrap(),
166            Some(MarkerSpec::PassThrough)
167        ));
168    }
169
170    #[test]
171    fn marker_with_links_is_explicit() {
172        let tmp = TempDir::new().unwrap();
173        std::fs::write(
174            tmp.path().join(".yuilink"),
175            r#"
176[[link]]
177dst = "/a"
178
179[[link]]
180dst = "/b"
181when = "yui.os == 'windows'"
182"#,
183        )
184        .unwrap();
185        let spec = read_spec(&root(&tmp), ".yuilink").unwrap().unwrap();
186        match spec {
187            MarkerSpec::Explicit { links } => {
188                assert_eq!(links.len(), 2);
189                assert!(links[0].src.is_none());
190                assert_eq!(links[0].dst, "/a");
191                assert!(links[0].when.is_none());
192                assert!(links[1].src.is_none());
193                assert_eq!(links[1].dst, "/b");
194                assert_eq!(links[1].when.as_deref(), Some("yui.os == 'windows'"));
195            }
196            _ => panic!("expected Explicit"),
197        }
198    }
199
200    #[test]
201    fn marker_with_file_src_parses() {
202        let tmp = TempDir::new().unwrap();
203        std::fs::write(
204            tmp.path().join(".yuilink"),
205            r#"
206[[link]]
207src = "profile.ps1"
208dst = "~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
209when = "yui.os == 'windows'"
210"#,
211        )
212        .unwrap();
213        let spec = read_spec(&root(&tmp), ".yuilink").unwrap().unwrap();
214        match spec {
215            MarkerSpec::Explicit { links } => {
216                assert_eq!(links.len(), 1);
217                assert_eq!(links[0].src.as_deref(), Some("profile.ps1"));
218                assert_eq!(
219                    links[0].dst,
220                    "~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
221                );
222            }
223            _ => panic!("expected Explicit"),
224        }
225    }
226
227    #[test]
228    fn marker_src_with_path_separator_errors() {
229        let tmp = TempDir::new().unwrap();
230        std::fs::write(
231            tmp.path().join(".yuilink"),
232            r#"
233[[link]]
234src = "sub/file.txt"
235dst = "/anywhere"
236"#,
237        )
238        .unwrap();
239        let err = read_spec(&root(&tmp), ".yuilink").unwrap_err();
240        assert!(format!("{err}").contains("single filename"));
241    }
242
243    #[test]
244    fn marker_src_dot_or_dotdot_errors() {
245        // `.` / `..` would silently escape the marker dir or point at
246        // the dir itself; neither is what `[[link]] src` is for.
247        for bad in [".", ".."] {
248            let tmp = TempDir::new().unwrap();
249            std::fs::write(
250                tmp.path().join(".yuilink"),
251                format!(
252                    r#"
253[[link]]
254src = "{bad}"
255dst = "/anywhere"
256"#
257                ),
258            )
259            .unwrap();
260            let err = read_spec(&root(&tmp), ".yuilink").unwrap_err();
261            assert!(
262                format!("{err}").contains("single filename"),
263                "expected rejection for src = {bad:?}, got {err}"
264            );
265        }
266    }
267
268    #[test]
269    fn marker_with_invalid_toml_errors() {
270        let tmp = TempDir::new().unwrap();
271        std::fs::write(tmp.path().join(".yuilink"), "this is not toml ===").unwrap();
272        let err = read_spec(&root(&tmp), ".yuilink").unwrap_err();
273        assert!(format!("{err}").contains("parse"));
274    }
275
276    #[test]
277    fn empty_link_array_is_passthrough() {
278        // Has a [[link]] header but no entries (rare but valid TOML).
279        let tmp = TempDir::new().unwrap();
280        std::fs::write(tmp.path().join(".yuilink"), "# no links\n").unwrap();
281        assert!(matches!(
282            read_spec(&root(&tmp), ".yuilink").unwrap(),
283            Some(MarkerSpec::PassThrough)
284        ));
285    }
286}