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** → "junction this dir at each
7//!     entry's `dst`, when filtered" — overrides the parent mount's dst.
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
19use camino::Utf8Path;
20use serde::Deserialize;
21
22use crate::{Error, Result};
23
24#[derive(Debug, Clone)]
25pub enum MarkerSpec {
26    /// Empty marker — link this dir using the parent mount's dst.
27    PassThrough,
28    /// Per-dir override; each entry produces a link (after `when` filter).
29    /// The parent mount's dst is bypassed for this directory.
30    Override { links: Vec<MarkerLink> },
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct MarkerLink {
35    pub dst: String,
36    #[serde(default)]
37    pub when: Option<String>,
38}
39
40#[derive(Deserialize)]
41struct MarkerFile {
42    #[serde(default)]
43    link: Vec<MarkerLink>,
44}
45
46/// Read and parse a `.yuilink` from `dir`.
47///
48/// Returns:
49///   - `Ok(None)` — no marker file present
50///   - `Ok(Some(PassThrough))` — present and empty / whitespace-only / no `[[link]]`
51///   - `Ok(Some(Override { ... }))` — present with `[[link]]` entries
52///   - `Err(_)` — present but malformed TOML, or other IO error
53pub fn read_spec(dir: &Utf8Path, marker_filename: &str) -> Result<Option<MarkerSpec>> {
54    let path = dir.join(marker_filename);
55    let raw = match std::fs::read_to_string(&path) {
56        Ok(s) => s,
57        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
58        Err(e) => return Err(Error::Io(e)),
59    };
60    if raw.trim().is_empty() {
61        return Ok(Some(MarkerSpec::PassThrough));
62    }
63    let parsed: MarkerFile =
64        toml::from_str(&raw).map_err(|e| Error::Config(format!("parse {path}: {e}")))?;
65    if parsed.link.is_empty() {
66        return Ok(Some(MarkerSpec::PassThrough));
67    }
68    Ok(Some(MarkerSpec::Override { links: parsed.link }))
69}
70
71/// Presence-only check: any `.yuilink` file (empty or with content) counts.
72/// Kept for callers that don't need the spec contents.
73pub fn is_marker_dir(dir: &Utf8Path, marker_filename: &str) -> bool {
74    dir.join(marker_filename).is_file()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use camino::Utf8PathBuf;
81    use tempfile::TempDir;
82
83    fn root(tmp: &TempDir) -> Utf8PathBuf {
84        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
85    }
86
87    #[test]
88    fn no_marker_returns_none() {
89        let tmp = TempDir::new().unwrap();
90        assert!(read_spec(&root(&tmp), ".yuilink").unwrap().is_none());
91    }
92
93    #[test]
94    fn empty_marker_is_passthrough() {
95        let tmp = TempDir::new().unwrap();
96        std::fs::write(tmp.path().join(".yuilink"), "").unwrap();
97        assert!(matches!(
98            read_spec(&root(&tmp), ".yuilink").unwrap(),
99            Some(MarkerSpec::PassThrough)
100        ));
101    }
102
103    #[test]
104    fn whitespace_only_marker_is_passthrough() {
105        let tmp = TempDir::new().unwrap();
106        std::fs::write(tmp.path().join(".yuilink"), "  \n\n").unwrap();
107        assert!(matches!(
108            read_spec(&root(&tmp), ".yuilink").unwrap(),
109            Some(MarkerSpec::PassThrough)
110        ));
111    }
112
113    #[test]
114    fn marker_with_links_is_override() {
115        let tmp = TempDir::new().unwrap();
116        std::fs::write(
117            tmp.path().join(".yuilink"),
118            r#"
119[[link]]
120dst = "/a"
121
122[[link]]
123dst = "/b"
124when = "{{ yui.os == 'windows' }}"
125"#,
126        )
127        .unwrap();
128        let spec = read_spec(&root(&tmp), ".yuilink").unwrap().unwrap();
129        match spec {
130            MarkerSpec::Override { links } => {
131                assert_eq!(links.len(), 2);
132                assert_eq!(links[0].dst, "/a");
133                assert!(links[0].when.is_none());
134                assert_eq!(links[1].dst, "/b");
135                assert_eq!(links[1].when.as_deref(), Some("{{ yui.os == 'windows' }}"));
136            }
137            _ => panic!("expected Override"),
138        }
139    }
140
141    #[test]
142    fn marker_with_invalid_toml_errors() {
143        let tmp = TempDir::new().unwrap();
144        std::fs::write(tmp.path().join(".yuilink"), "this is not toml ===").unwrap();
145        let err = read_spec(&root(&tmp), ".yuilink").unwrap_err();
146        assert!(format!("{err}").contains("parse"));
147    }
148
149    #[test]
150    fn empty_link_array_is_passthrough() {
151        // Has a [[link]] header but no entries (rare but valid TOML).
152        let tmp = TempDir::new().unwrap();
153        std::fs::write(tmp.path().join(".yuilink"), "# no links\n").unwrap();
154        assert!(matches!(
155            read_spec(&root(&tmp), ".yuilink").unwrap(),
156            Some(MarkerSpec::PassThrough)
157        ));
158    }
159}