1use camino::{Utf8Path, Utf8PathBuf};
15use serde::Deserialize;
16
17use crate::vars::YuiVars;
18use crate::{Error, Result, template};
19
20#[derive(Debug, Deserialize, Default)]
21pub struct Config {
22 #[serde(default)]
23 pub vars: toml::Table,
24
25 #[serde(default)]
26 pub link: LinkConfig,
27
28 #[serde(default)]
29 pub mount: MountConfig,
30
31 #[serde(default)]
32 pub absorb: AbsorbConfig,
33
34 #[serde(default)]
35 pub render: RenderConfig,
36
37 #[serde(default)]
38 pub backup: BackupConfig,
39}
40
41#[derive(Debug, Deserialize, Default)]
42pub struct LinkConfig {
43 #[serde(default)]
44 pub file_mode: FileLinkMode,
45 #[serde(default)]
46 pub dir_mode: DirLinkMode,
47}
48
49#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum FileLinkMode {
52 #[default]
53 Auto,
54 Symlink,
55 Hardlink,
56}
57
58#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
59#[serde(rename_all = "lowercase")]
60pub enum DirLinkMode {
61 #[default]
62 Auto,
63 Symlink,
64 Junction,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct MountConfig {
69 #[serde(default)]
70 pub default_strategy: MountStrategy,
71 #[serde(default = "default_marker_filename")]
72 pub marker_filename: String,
73 #[serde(default)]
74 pub entry: Vec<MountEntry>,
75}
76
77impl Default for MountConfig {
78 fn default() -> Self {
79 Self {
80 default_strategy: MountStrategy::default(),
81 marker_filename: default_marker_filename(),
82 entry: Vec::new(),
83 }
84 }
85}
86
87fn default_marker_filename() -> String {
88 ".yuilink".to_string()
89}
90
91#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "kebab-case")]
93pub enum MountStrategy {
94 #[default]
95 Marker,
96 PerFile,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct MountEntry {
101 pub src: Utf8PathBuf,
102 pub dst: String,
103 #[serde(default)]
104 pub when: Option<String>,
105 #[serde(default)]
106 pub strategy: Option<MountStrategy>,
107}
108
109#[derive(Debug, Deserialize)]
110pub struct AbsorbConfig {
111 #[serde(default = "default_true")]
112 pub auto: bool,
113 #[serde(default = "default_true")]
114 pub require_clean_git: bool,
115 #[serde(default)]
116 pub on_anomaly: AnomalyAction,
117}
118
119impl Default for AbsorbConfig {
120 fn default() -> Self {
121 Self {
122 auto: true,
123 require_clean_git: true,
124 on_anomaly: AnomalyAction::default(),
125 }
126 }
127}
128
129#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
130#[serde(rename_all = "lowercase")]
131pub enum AnomalyAction {
132 #[default]
133 Ask,
134 Skip,
135 Force,
136}
137
138#[derive(Debug, Deserialize)]
139pub struct RenderConfig {
140 #[serde(default = "default_true")]
141 pub manage_gitignore: bool,
142 #[serde(default)]
143 pub rule: Vec<RenderRule>,
144}
145
146impl Default for RenderConfig {
147 fn default() -> Self {
148 Self {
149 manage_gitignore: true,
150 rule: Vec::new(),
151 }
152 }
153}
154
155#[derive(Debug, Deserialize)]
156pub struct RenderRule {
157 pub r#match: String,
158 #[serde(default)]
159 pub when: Option<String>,
160}
161
162#[derive(Debug, Deserialize)]
163pub struct BackupConfig {
164 #[serde(default = "default_backup_dir")]
165 pub dir: String,
166 #[serde(default = "default_ts_format")]
167 pub timestamp_format: String,
168}
169
170impl Default for BackupConfig {
171 fn default() -> Self {
172 Self {
173 dir: default_backup_dir(),
174 timestamp_format: default_ts_format(),
175 }
176 }
177}
178
179fn default_backup_dir() -> String {
180 ".yui/backup".to_string()
181}
182
183fn default_ts_format() -> String {
184 "%Y%m%d_%H%M%S%3f".to_string()
185}
186
187fn default_true() -> bool {
188 true
189}
190
191pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
193 let files = list_config_files(source)?;
194 if files.is_empty() {
195 return Err(Error::Config(format!(
196 "no config.toml / config.*.toml found at {source}"
197 )));
198 }
199
200 let mut engine = template::Engine::new();
201 let mut merged = toml::Table::new();
202 let mut vars_acc = toml::Table::new();
203
204 for file in &files {
205 let raw = std::fs::read_to_string(file)
206 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
207 let ctx = template::template_context(yui, &vars_acc);
208 let rendered = engine.render(&raw, &ctx)?;
209 let parsed: toml::Table =
210 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
211
212 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
213 deep_merge_table(&mut vars_acc, file_vars.clone());
214 }
215 deep_merge_table(&mut merged, parsed);
216 }
217
218 let cfg: Config = toml::Value::Table(merged)
219 .try_into()
220 .map_err(|e| Error::Config(format!("schema: {e}")))?;
221 Ok(cfg)
222}
223
224fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
229 let entries =
230 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
231 let mut files: Vec<Utf8PathBuf> = Vec::new();
232 for entry in entries {
233 let entry = entry.map_err(Error::Io)?;
234 let name_os = entry.file_name();
235 let Some(name) = name_os.to_str() else {
236 continue;
237 };
238 let is_match = name == "config.toml"
239 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
240 if !is_match {
241 continue;
242 }
243 let path = Utf8PathBuf::from_path_buf(entry.path())
244 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
245 files.push(path);
246 }
247 files.sort_by(|a, b| {
248 let an = a.file_name().unwrap_or("");
249 let bn = b.file_name().unwrap_or("");
250 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
251 });
252 Ok(files)
253}
254
255fn file_rank(name: &str) -> u8 {
256 match name {
257 "config.toml" => 0,
258 "config.local.toml" => 2,
259 _ => 1,
260 }
261}
262
263fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
266 for (k, v) in overlay {
267 match (base.remove(&k), v) {
268 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
269 deep_merge_table(&mut bt, ot);
270 base.insert(k, toml::Value::Table(bt));
271 }
272 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
273 ba.extend(oa);
274 base.insert(k, toml::Value::Array(ba));
275 }
276 (_, v) => {
277 base.insert(k, v);
278 }
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use tempfile::TempDir;
287
288 fn yui_vars(source: &Utf8Path) -> YuiVars {
289 YuiVars {
290 os: "linux".into(),
291 arch: "x86_64".into(),
292 host: "test".into(),
293 user: "u".into(),
294 source: source.to_string(),
295 }
296 }
297
298 fn write(tmp: &TempDir, name: &str, body: &str) {
299 std::fs::write(tmp.path().join(name), body).unwrap();
300 }
301
302 fn root(tmp: &TempDir) -> Utf8PathBuf {
303 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
304 }
305
306 #[test]
307 fn loads_single_file() {
308 let tmp = TempDir::new().unwrap();
309 write(
310 &tmp,
311 "config.toml",
312 r#"
313[vars]
314git_email = "a@example.com"
315
316[[mount.entry]]
317src = "home"
318dst = "/home/u"
319"#,
320 );
321 let r = root(&tmp);
322 let cfg = load(&r, &yui_vars(&r)).unwrap();
323 assert_eq!(
324 cfg.vars.get("git_email").unwrap().as_str(),
325 Some("a@example.com")
326 );
327 assert_eq!(cfg.mount.entry.len(), 1);
328 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
329 }
330
331 #[test]
332 fn local_overrides_base() {
333 let tmp = TempDir::new().unwrap();
334 write(
335 &tmp,
336 "config.toml",
337 r#"
338[vars]
339git_email = "a@example.com"
340work_mode = false
341"#,
342 );
343 write(
344 &tmp,
345 "config.local.toml",
346 r#"
347[vars]
348git_email = "b@work.com"
349"#,
350 );
351 let r = root(&tmp);
352 let cfg = load(&r, &yui_vars(&r)).unwrap();
353 assert_eq!(
354 cfg.vars.get("git_email").unwrap().as_str(),
355 Some("b@work.com")
356 );
357 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
359 }
360
361 #[test]
362 fn alphabetical_middle_files_apply_after_base_before_local() {
363 let tmp = TempDir::new().unwrap();
364 write(
365 &tmp,
366 "config.toml",
367 r#"[vars]
368val = "base""#,
369 );
370 write(
371 &tmp,
372 "config.aaa.toml",
373 r#"[vars]
374val = "aaa""#,
375 );
376 write(
377 &tmp,
378 "config.zzz.toml",
379 r#"[vars]
380val = "zzz""#,
381 );
382 write(
383 &tmp,
384 "config.local.toml",
385 r#"[vars]
386val = "local""#,
387 );
388 let r = root(&tmp);
389 let cfg = load(&r, &yui_vars(&r)).unwrap();
390 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
391 }
392
393 #[test]
394 fn yui_vars_available_in_render() {
395 let tmp = TempDir::new().unwrap();
396 write(
397 &tmp,
398 "config.toml",
399 r#"
400[[mount.entry]]
401src = "home"
402dst = "/{{ yui.os }}/dst"
403"#,
404 );
405 let r = root(&tmp);
406 let cfg = load(&r, &yui_vars(&r)).unwrap();
407 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
408 }
409
410 #[test]
411 fn mount_entries_append_across_files() {
412 let tmp = TempDir::new().unwrap();
413 write(
414 &tmp,
415 "config.toml",
416 r#"
417[[mount.entry]]
418src = "home"
419dst = "/h"
420"#,
421 );
422 write(
423 &tmp,
424 "config.local.toml",
425 r#"
426[[mount.entry]]
427src = "appdata"
428dst = "/a"
429"#,
430 );
431 let r = root(&tmp);
432 let cfg = load(&r, &yui_vars(&r)).unwrap();
433 assert_eq!(cfg.mount.entry.len(), 2);
434 }
435
436 #[test]
437 fn missing_config_errors() {
438 let tmp = TempDir::new().unwrap();
439 let r = root(&tmp);
440 let err = load(&r, &yui_vars(&r)).unwrap_err();
441 assert!(matches!(err, Error::Config(_)));
442 }
443
444 #[test]
445 fn defaults_apply_when_sections_absent() {
446 let tmp = TempDir::new().unwrap();
447 write(&tmp, "config.toml", "");
448 let r = root(&tmp);
449 let cfg = load(&r, &yui_vars(&r)).unwrap();
450 assert!(cfg.absorb.auto);
451 assert!(cfg.absorb.require_clean_git);
452 assert!(cfg.render.manage_gitignore);
453 assert_eq!(cfg.backup.dir, ".yui/backup");
454 assert_eq!(cfg.mount.marker_filename, ".yuilink");
455 }
456}