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 #[serde(default)]
41 pub ui: UiConfig,
42
43 #[serde(default)]
44 pub hook: Vec<HookConfig>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
55pub struct HookConfig {
56 pub name: String,
59 pub script: Utf8PathBuf,
62
63 #[serde(default = "default_hook_command")]
65 pub command: String,
66 #[serde(default = "default_hook_args")]
69 pub args: Vec<String>,
70
71 #[serde(default)]
73 pub when_run: WhenRun,
74 #[serde(default)]
76 pub phase: HookPhase,
77
78 #[serde(default)]
80 pub when: Option<String>,
81}
82
83fn default_hook_command() -> String {
84 "bash".to_string()
85}
86
87fn default_hook_args() -> Vec<String> {
88 vec!["{{ script_path }}".to_string()]
89}
90
91#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "lowercase")]
93pub enum WhenRun {
94 Once,
97 #[default]
101 Onchange,
102 Every,
104}
105
106#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
107#[serde(rename_all = "lowercase")]
108pub enum HookPhase {
109 Pre,
111 #[default]
114 Post,
115}
116
117#[derive(Debug, Deserialize, Default)]
118pub struct UiConfig {
119 #[serde(default)]
120 pub icons: IconsMode,
121}
122
123#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
124#[serde(rename_all = "lowercase")]
125pub enum IconsMode {
126 #[default]
128 Unicode,
129 Nerd,
131 Ascii,
133}
134
135#[derive(Debug, Deserialize, Default)]
136pub struct LinkConfig {
137 #[serde(default)]
138 pub file_mode: FileLinkMode,
139 #[serde(default)]
140 pub dir_mode: DirLinkMode,
141}
142
143#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum FileLinkMode {
146 #[default]
147 Auto,
148 Symlink,
149 Hardlink,
150}
151
152#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum DirLinkMode {
155 #[default]
156 Auto,
157 Symlink,
158 Junction,
159}
160
161#[derive(Debug, Deserialize)]
162pub struct MountConfig {
163 #[serde(default)]
164 pub default_strategy: MountStrategy,
165 #[serde(default = "default_marker_filename")]
166 pub marker_filename: String,
167 #[serde(default)]
168 pub entry: Vec<MountEntry>,
169}
170
171impl Default for MountConfig {
172 fn default() -> Self {
173 Self {
174 default_strategy: MountStrategy::default(),
175 marker_filename: default_marker_filename(),
176 entry: Vec::new(),
177 }
178 }
179}
180
181fn default_marker_filename() -> String {
182 ".yuilink".to_string()
183}
184
185#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
186#[serde(rename_all = "kebab-case")]
187pub enum MountStrategy {
188 #[default]
189 Marker,
190 PerFile,
191}
192
193#[derive(Debug, Deserialize)]
194pub struct MountEntry {
195 pub src: Utf8PathBuf,
196 pub dst: String,
197 #[serde(default)]
198 pub when: Option<String>,
199 #[serde(default)]
200 pub strategy: Option<MountStrategy>,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct AbsorbConfig {
205 #[serde(default = "default_true")]
206 pub auto: bool,
207 #[serde(default = "default_true")]
208 pub require_clean_git: bool,
209 #[serde(default)]
210 pub on_anomaly: AnomalyAction,
211}
212
213impl Default for AbsorbConfig {
214 fn default() -> Self {
215 Self {
216 auto: true,
217 require_clean_git: true,
218 on_anomaly: AnomalyAction::default(),
219 }
220 }
221}
222
223#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
224#[serde(rename_all = "lowercase")]
225pub enum AnomalyAction {
226 #[default]
227 Ask,
228 Skip,
229 Force,
230}
231
232#[derive(Debug, Deserialize)]
233pub struct RenderConfig {
234 #[serde(default = "default_true")]
235 pub manage_gitignore: bool,
236 #[serde(default)]
237 pub rule: Vec<RenderRule>,
238}
239
240impl Default for RenderConfig {
241 fn default() -> Self {
242 Self {
243 manage_gitignore: true,
244 rule: Vec::new(),
245 }
246 }
247}
248
249#[derive(Debug, Deserialize)]
250pub struct RenderRule {
251 pub r#match: String,
252 #[serde(default)]
253 pub when: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257pub struct BackupConfig {
258 #[serde(default = "default_backup_dir")]
259 pub dir: String,
260 #[serde(default = "default_ts_format")]
261 pub timestamp_format: String,
262}
263
264impl Default for BackupConfig {
265 fn default() -> Self {
266 Self {
267 dir: default_backup_dir(),
268 timestamp_format: default_ts_format(),
269 }
270 }
271}
272
273fn default_backup_dir() -> String {
274 ".yui/backup".to_string()
275}
276
277fn default_ts_format() -> String {
278 "%Y%m%d_%H%M%S%3f".to_string()
279}
280
281fn default_true() -> bool {
282 true
283}
284
285pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
287 let files = list_config_files(source)?;
288 if files.is_empty() {
289 return Err(Error::Config(format!(
290 "no config.toml / config.*.toml found at {source}"
291 )));
292 }
293
294 let mut engine = template::Engine::new();
295 let mut merged = toml::Table::new();
296 let mut vars_acc = toml::Table::new();
297
298 for file in &files {
299 let raw = std::fs::read_to_string(file)
300 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
301
302 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
308 deep_merge_table(&mut vars_acc, file_vars);
309 }
310 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
316
317 let ctx = template::template_context(yui, &vars_acc);
318 let rendered = engine.render(&raw, &ctx)?;
319 let parsed: toml::Table =
320 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
321
322 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
327 deep_merge_table(&mut vars_acc, file_vars.clone());
328 }
329 deep_merge_table(&mut merged, parsed);
330 }
331
332 let cfg: Config = toml::Value::Table(merged)
333 .try_into()
334 .map_err(|e| Error::Config(format!("schema: {e}")))?;
335 Ok(cfg)
336}
337
338fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
348 let mut in_vars = false;
349 let mut found_vars = false;
350 let mut lines: Vec<&str> = Vec::new();
351 for line in raw.lines() {
352 let trimmed = line.trim();
353 let header = trimmed.split('#').next().unwrap_or("").trim();
356 if header.starts_with("[") {
357 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
360 if normalized == "[vars]"
361 || normalized.starts_with("[vars.")
362 || normalized.starts_with("[vars[")
363 {
364 in_vars = true;
365 found_vars = true;
366 lines.push(line);
367 continue;
368 }
369 in_vars = false;
370 continue;
371 }
372 if trimmed.starts_with("{%") {
377 continue;
378 }
379 if in_vars {
380 lines.push(line);
381 }
382 }
383 if !found_vars {
384 return Ok(None);
385 }
386 let extracted = lines.join("\n");
387 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
388 Error::Config(format!(
389 "pre-extract [vars] from {file}: {e} \
390 (the [vars] block must be parseable on its own — \
391 move computed values into a `set` block above the section)"
392 ))
393 })?;
394 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
395 Ok(Some(vars.clone()))
396 } else {
397 Ok(None)
398 }
399}
400
401const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
407
408fn resolve_vars_refs(
412 vars: &mut toml::Table,
413 yui: &YuiVars,
414 engine: &mut template::Engine,
415) -> Result<()> {
416 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
417 let ctx = template::template_context(yui, vars);
418 let mut changed = false;
419 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
420 if !changed {
421 return Ok(());
422 }
423 }
424 Ok(())
429}
430
431fn render_strings_in_table(
432 table: &mut toml::Table,
433 engine: &mut template::Engine,
434 ctx: &tera::Context,
435 changed: &mut bool,
436) -> Result<()> {
437 for (_k, value) in table.iter_mut() {
438 render_strings_in_value(value, engine, ctx, changed)?;
439 }
440 Ok(())
441}
442
443fn render_strings_in_value(
444 value: &mut toml::Value,
445 engine: &mut template::Engine,
446 ctx: &tera::Context,
447 changed: &mut bool,
448) -> Result<()> {
449 match value {
450 toml::Value::String(s) => {
451 if !s.contains("{{") && !s.contains("{%") {
452 return Ok(());
453 }
454 let rendered = engine.render(s.as_str(), ctx)?;
455 if rendered != *s {
456 *s = rendered;
457 *changed = true;
458 }
459 }
460 toml::Value::Table(t) => {
461 render_strings_in_table(t, engine, ctx, changed)?;
462 }
463 toml::Value::Array(arr) => {
464 for v in arr.iter_mut() {
465 render_strings_in_value(v, engine, ctx, changed)?;
466 }
467 }
468 _ => {}
469 }
470 Ok(())
471}
472
473fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
478 let entries =
479 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
480 let mut files: Vec<Utf8PathBuf> = Vec::new();
481 for entry in entries {
482 let entry = entry.map_err(Error::Io)?;
483 let name_os = entry.file_name();
484 let Some(name) = name_os.to_str() else {
485 continue;
486 };
487 let is_match = name == "config.toml"
488 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
489 if !is_match {
490 continue;
491 }
492 let path = Utf8PathBuf::from_path_buf(entry.path())
493 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
494 files.push(path);
495 }
496 files.sort_by(|a, b| {
497 let an = a.file_name().unwrap_or("");
498 let bn = b.file_name().unwrap_or("");
499 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
500 });
501 Ok(files)
502}
503
504fn file_rank(name: &str) -> u8 {
505 match name {
506 "config.toml" => 0,
507 "config.local.toml" => 2,
508 _ => 1,
509 }
510}
511
512fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
515 for (k, v) in overlay {
516 match (base.remove(&k), v) {
517 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
518 deep_merge_table(&mut bt, ot);
519 base.insert(k, toml::Value::Table(bt));
520 }
521 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
522 ba.extend(oa);
523 base.insert(k, toml::Value::Array(ba));
524 }
525 (_, v) => {
526 base.insert(k, v);
527 }
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use tempfile::TempDir;
536
537 fn yui_vars(source: &Utf8Path) -> YuiVars {
538 YuiVars {
539 os: "linux".into(),
540 arch: "x86_64".into(),
541 host: "test".into(),
542 user: "u".into(),
543 source: source.to_string(),
544 }
545 }
546
547 fn write(tmp: &TempDir, name: &str, body: &str) {
548 std::fs::write(tmp.path().join(name), body).unwrap();
549 }
550
551 fn root(tmp: &TempDir) -> Utf8PathBuf {
552 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
553 }
554
555 #[test]
556 fn loads_single_file() {
557 let tmp = TempDir::new().unwrap();
558 write(
559 &tmp,
560 "config.toml",
561 r#"
562[vars]
563git_email = "a@example.com"
564
565[[mount.entry]]
566src = "home"
567dst = "/home/u"
568"#,
569 );
570 let r = root(&tmp);
571 let cfg = load(&r, &yui_vars(&r)).unwrap();
572 assert_eq!(
573 cfg.vars.get("git_email").unwrap().as_str(),
574 Some("a@example.com")
575 );
576 assert_eq!(cfg.mount.entry.len(), 1);
577 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
578 }
579
580 #[test]
581 fn local_overrides_base() {
582 let tmp = TempDir::new().unwrap();
583 write(
584 &tmp,
585 "config.toml",
586 r#"
587[vars]
588git_email = "a@example.com"
589work_mode = false
590"#,
591 );
592 write(
593 &tmp,
594 "config.local.toml",
595 r#"
596[vars]
597git_email = "b@work.com"
598"#,
599 );
600 let r = root(&tmp);
601 let cfg = load(&r, &yui_vars(&r)).unwrap();
602 assert_eq!(
603 cfg.vars.get("git_email").unwrap().as_str(),
604 Some("b@work.com")
605 );
606 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
608 }
609
610 #[test]
611 fn alphabetical_middle_files_apply_after_base_before_local() {
612 let tmp = TempDir::new().unwrap();
613 write(
614 &tmp,
615 "config.toml",
616 r#"[vars]
617val = "base""#,
618 );
619 write(
620 &tmp,
621 "config.aaa.toml",
622 r#"[vars]
623val = "aaa""#,
624 );
625 write(
626 &tmp,
627 "config.zzz.toml",
628 r#"[vars]
629val = "zzz""#,
630 );
631 write(
632 &tmp,
633 "config.local.toml",
634 r#"[vars]
635val = "local""#,
636 );
637 let r = root(&tmp);
638 let cfg = load(&r, &yui_vars(&r)).unwrap();
639 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
640 }
641
642 #[test]
643 fn yui_vars_available_in_render() {
644 let tmp = TempDir::new().unwrap();
645 write(
646 &tmp,
647 "config.toml",
648 r#"
649[[mount.entry]]
650src = "home"
651dst = "/{{ yui.os }}/dst"
652"#,
653 );
654 let r = root(&tmp);
655 let cfg = load(&r, &yui_vars(&r)).unwrap();
656 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
657 }
658
659 #[test]
660 fn mount_entries_append_across_files() {
661 let tmp = TempDir::new().unwrap();
662 write(
663 &tmp,
664 "config.toml",
665 r#"
666[[mount.entry]]
667src = "home"
668dst = "/h"
669"#,
670 );
671 write(
672 &tmp,
673 "config.local.toml",
674 r#"
675[[mount.entry]]
676src = "appdata"
677dst = "/a"
678"#,
679 );
680 let r = root(&tmp);
681 let cfg = load(&r, &yui_vars(&r)).unwrap();
682 assert_eq!(cfg.mount.entry.len(), 2);
683 }
684
685 #[test]
686 fn missing_config_errors() {
687 let tmp = TempDir::new().unwrap();
688 let r = root(&tmp);
689 let err = load(&r, &yui_vars(&r)).unwrap_err();
690 assert!(matches!(err, Error::Config(_)));
691 }
692
693 #[test]
694 fn defaults_apply_when_sections_absent() {
695 let tmp = TempDir::new().unwrap();
696 write(&tmp, "config.toml", "");
697 let r = root(&tmp);
698 let cfg = load(&r, &yui_vars(&r)).unwrap();
699 assert!(cfg.absorb.auto);
700 assert!(cfg.absorb.require_clean_git);
701 assert!(cfg.render.manage_gitignore);
702 assert_eq!(cfg.backup.dir, ".yui/backup");
703 assert_eq!(cfg.mount.marker_filename, ".yuilink");
704 }
705
706 #[test]
711 fn vars_visible_to_same_file_render() {
712 let tmp = TempDir::new().unwrap();
713 write(
714 &tmp,
715 "config.toml",
716 r#"
717[vars]
718home_root = "/custom/home"
719
720[[mount.entry]]
721src = "home"
722dst = "{{ vars.home_root }}"
723"#,
724 );
725 let r = root(&tmp);
726 let cfg = load(&r, &yui_vars(&r)).unwrap();
727 assert_eq!(cfg.mount.entry.len(), 1);
728 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
729 }
730
731 #[test]
735 fn vars_extract_skips_set_blocks() {
736 let tmp = TempDir::new().unwrap();
737 write(
738 &tmp,
739 "config.toml",
740 r#"
741{% set computed = "abc" %}
742[vars]
743plain = "real"
744
745[[mount.entry]]
746src = "home"
747dst = "{{ vars.plain }}"
748"#,
749 );
750 let r = root(&tmp);
751 let cfg = load(&r, &yui_vars(&r)).unwrap();
752 assert_eq!(cfg.mount.entry[0].dst, "real");
753 }
754
755 #[test]
758 fn vars_cross_reference_resolves_either_order() {
759 let tmp = TempDir::new().unwrap();
760 write(
761 &tmp,
762 "config.toml",
763 r#"
764[vars]
765a = "{{ vars.b }}"
766b = "raw"
767
768[[mount.entry]]
769src = "home"
770dst = "{{ vars.a }}"
771"#,
772 );
773 let r = root(&tmp);
774 let cfg = load(&r, &yui_vars(&r)).unwrap();
775 assert_eq!(cfg.mount.entry[0].dst, "raw");
776 }
777
778 #[test]
784 fn vars_cycle_does_not_loop_forever() {
785 let tmp = TempDir::new().unwrap();
786 write(
787 &tmp,
788 "config.toml",
789 r#"
790[vars]
791a = "{{ vars.b }}"
792b = "{{ vars.a }}"
793
794[[mount.entry]]
795src = "home"
796dst = "/anywhere"
797"#,
798 );
799 let r = root(&tmp);
800 let cfg = load(&r, &yui_vars(&r)).unwrap();
804 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
805 }
806}