1use camino::Utf8Path;
53use serde::Deserialize;
54
55use crate::{Error, Result};
56
57#[derive(Debug, Clone)]
58pub enum MarkerSpec {
59 PassThrough,
61 Explicit { links: Vec<MarkerLink> },
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct MarkerLink {
68 #[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
85pub 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 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
128pub 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 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 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}