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 #[serde(default)]
47 pub secrets: SecretsConfig,
48}
49
50#[derive(Debug, Clone, Deserialize)]
58pub struct HookConfig {
59 pub name: String,
62 pub script: Utf8PathBuf,
65
66 #[serde(default = "default_hook_command")]
68 pub command: String,
69 #[serde(default = "default_hook_args")]
72 pub args: Vec<String>,
73
74 #[serde(default)]
76 pub when_run: WhenRun,
77 #[serde(default)]
79 pub phase: HookPhase,
80
81 #[serde(default)]
83 pub when: Option<String>,
84}
85
86fn default_hook_command() -> String {
87 "bash".to_string()
88}
89
90fn default_hook_args() -> Vec<String> {
91 vec!["{{ script_path }}".to_string()]
92}
93
94#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
95#[serde(rename_all = "lowercase")]
96pub enum WhenRun {
97 Once,
100 #[default]
104 Onchange,
105 Every,
107}
108
109#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
110#[serde(rename_all = "lowercase")]
111pub enum HookPhase {
112 Pre,
114 #[default]
117 Post,
118}
119
120#[derive(Debug, Deserialize, Default)]
121pub struct UiConfig {
122 #[serde(default)]
123 pub icons: IconsMode,
124}
125
126#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
127#[serde(rename_all = "lowercase")]
128pub enum IconsMode {
129 #[default]
131 Unicode,
132 Nerd,
134 Ascii,
136}
137
138#[derive(Debug, Deserialize, Default)]
139pub struct LinkConfig {
140 #[serde(default)]
141 pub file_mode: FileLinkMode,
142 #[serde(default)]
143 pub dir_mode: DirLinkMode,
144}
145
146#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
147#[serde(rename_all = "lowercase")]
148pub enum FileLinkMode {
149 #[default]
150 Auto,
151 Symlink,
152 Hardlink,
153}
154
155#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
156#[serde(rename_all = "lowercase")]
157pub enum DirLinkMode {
158 #[default]
159 Auto,
160 Symlink,
161 Junction,
162}
163
164#[derive(Debug, Deserialize)]
165pub struct MountConfig {
166 #[serde(default)]
167 pub default_strategy: MountStrategy,
168 #[serde(default = "default_marker_filename")]
169 pub marker_filename: String,
170 #[serde(default)]
171 pub entry: Vec<MountEntry>,
172}
173
174impl Default for MountConfig {
175 fn default() -> Self {
176 Self {
177 default_strategy: MountStrategy::default(),
178 marker_filename: default_marker_filename(),
179 entry: Vec::new(),
180 }
181 }
182}
183
184fn default_marker_filename() -> String {
185 ".yuilink".to_string()
186}
187
188#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
189#[serde(rename_all = "kebab-case")]
190pub enum MountStrategy {
191 #[default]
192 Marker,
193 PerFile,
194}
195
196#[derive(Debug, Deserialize)]
197pub struct MountEntry {
198 pub src: Utf8PathBuf,
199 pub dst: String,
200 #[serde(default)]
201 pub when: Option<String>,
202 #[serde(default)]
203 pub strategy: Option<MountStrategy>,
204}
205
206#[derive(Debug, Deserialize)]
207pub struct AbsorbConfig {
208 #[serde(default = "default_true")]
209 pub auto: bool,
210 #[serde(default = "default_true")]
211 pub require_clean_git: bool,
212 #[serde(default)]
213 pub on_anomaly: AnomalyAction,
214}
215
216impl Default for AbsorbConfig {
217 fn default() -> Self {
218 Self {
219 auto: true,
220 require_clean_git: true,
221 on_anomaly: AnomalyAction::default(),
222 }
223 }
224}
225
226#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
227#[serde(rename_all = "lowercase")]
228pub enum AnomalyAction {
229 #[default]
230 Ask,
231 Skip,
232 Force,
233}
234
235#[derive(Debug, Deserialize)]
236pub struct RenderConfig {
237 #[serde(default = "default_true")]
238 pub manage_gitignore: bool,
239 #[serde(default)]
240 pub rule: Vec<RenderRule>,
241}
242
243impl Default for RenderConfig {
244 fn default() -> Self {
245 Self {
246 manage_gitignore: true,
247 rule: Vec::new(),
248 }
249 }
250}
251
252#[derive(Debug, Deserialize)]
253pub struct RenderRule {
254 pub r#match: String,
255 #[serde(default)]
256 pub when: Option<String>,
257}
258
259#[derive(Debug, Deserialize)]
260pub struct BackupConfig {
261 #[serde(default = "default_backup_dir")]
262 pub dir: String,
263 #[serde(default = "default_ts_format")]
264 pub timestamp_format: String,
265}
266
267impl Default for BackupConfig {
268 fn default() -> Self {
269 Self {
270 dir: default_backup_dir(),
271 timestamp_format: default_ts_format(),
272 }
273 }
274}
275
276fn default_backup_dir() -> String {
277 ".yui/backup".to_string()
278}
279
280#[derive(Debug, Clone, Deserialize)]
290pub struct SecretsConfig {
291 #[serde(default = "default_identity_path")]
295 pub identity: String,
296
297 #[serde(default)]
310 pub recipients: Vec<String>,
311
312 #[serde(default)]
318 pub vault: Option<VaultConfig>,
319}
320
321impl Default for SecretsConfig {
322 fn default() -> Self {
323 Self {
324 identity: default_identity_path(),
325 recipients: Vec::new(),
326 vault: None,
327 }
328 }
329}
330
331#[derive(Debug, Clone, Deserialize)]
343pub struct VaultConfig {
344 pub provider: VaultProvider,
346 pub item: String,
349}
350
351#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
352#[serde(rename_all = "lowercase")]
353pub enum VaultProvider {
354 Bitwarden,
355 #[serde(alias = "1password")]
356 OnePassword,
357}
358
359impl SecretsConfig {
360 pub fn enabled(&self) -> bool {
365 !self.recipients.is_empty()
366 }
367}
368
369fn default_identity_path() -> String {
370 "~/.config/yui/age.txt".to_string()
374}
375
376fn default_ts_format() -> String {
377 "%Y%m%d_%H%M%S%3f".to_string()
378}
379
380fn default_true() -> bool {
381 true
382}
383
384pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
386 let files = list_config_files(source)?;
387 if files.is_empty() {
388 return Err(Error::Config(format!(
389 "no config.toml / config.*.toml found at {source}"
390 )));
391 }
392
393 let mut engine = template::Engine::new();
394 let mut merged = toml::Table::new();
395 let mut vars_acc = toml::Table::new();
396
397 for file in &files {
398 let raw = std::fs::read_to_string(file)
399 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
400
401 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
407 deep_merge_table(&mut vars_acc, file_vars);
408 }
409 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
415
416 let ctx = template::config_render_context(yui, &vars_acc);
420 let rendered = engine.render(&raw, &ctx)?;
421 let parsed: toml::Table =
422 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
423
424 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
429 deep_merge_table(&mut vars_acc, file_vars.clone());
430 }
431 deep_merge_table(&mut merged, parsed);
432 }
433
434 let cfg: Config = toml::Value::Table(merged)
435 .try_into()
436 .map_err(|e| Error::Config(format!("schema: {e}")))?;
437 Ok(cfg)
438}
439
440fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
450 let mut in_vars = false;
451 let mut found_vars = false;
452 let mut lines: Vec<&str> = Vec::new();
453 for line in raw.lines() {
454 let trimmed = line.trim();
455 let header = trimmed.split('#').next().unwrap_or("").trim();
458 if header.starts_with("[") {
459 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
462 if normalized == "[vars]"
463 || normalized.starts_with("[vars.")
464 || normalized.starts_with("[vars[")
465 {
466 in_vars = true;
467 found_vars = true;
468 lines.push(line);
469 continue;
470 }
471 in_vars = false;
472 continue;
473 }
474 if trimmed.starts_with("{%") {
479 continue;
480 }
481 if in_vars {
482 lines.push(line);
483 }
484 }
485 if !found_vars {
486 return Ok(None);
487 }
488 let extracted = lines.join("\n");
489 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
490 Error::Config(format!(
491 "pre-extract [vars] from {file}: {e} \
492 (the [vars] block must be parseable on its own — \
493 move computed values into a `set` block above the section)"
494 ))
495 })?;
496 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
497 Ok(Some(vars.clone()))
498 } else {
499 Ok(None)
500 }
501}
502
503const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
509
510fn resolve_vars_refs(
514 vars: &mut toml::Table,
515 yui: &YuiVars,
516 engine: &mut template::Engine,
517) -> Result<()> {
518 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
519 let ctx = template::config_render_context(yui, vars);
524 let mut changed = false;
525 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
526 if !changed {
527 return Ok(());
528 }
529 }
530 Ok(())
535}
536
537fn render_strings_in_table(
538 table: &mut toml::Table,
539 engine: &mut template::Engine,
540 ctx: &tera::Context,
541 changed: &mut bool,
542) -> Result<()> {
543 for (_k, value) in table.iter_mut() {
544 render_strings_in_value(value, engine, ctx, changed)?;
545 }
546 Ok(())
547}
548
549fn render_strings_in_value(
550 value: &mut toml::Value,
551 engine: &mut template::Engine,
552 ctx: &tera::Context,
553 changed: &mut bool,
554) -> Result<()> {
555 match value {
556 toml::Value::String(s) => {
557 if !s.contains("{{") && !s.contains("{%") {
558 return Ok(());
559 }
560 let rendered = engine.render(s.as_str(), ctx)?;
561 if rendered != *s {
562 *s = rendered;
563 *changed = true;
564 }
565 }
566 toml::Value::Table(t) => {
567 render_strings_in_table(t, engine, ctx, changed)?;
568 }
569 toml::Value::Array(arr) => {
570 for v in arr.iter_mut() {
571 render_strings_in_value(v, engine, ctx, changed)?;
572 }
573 }
574 _ => {}
575 }
576 Ok(())
577}
578
579fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
584 let entries =
585 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
586 let mut files: Vec<Utf8PathBuf> = Vec::new();
587 for entry in entries {
588 let entry = entry.map_err(Error::Io)?;
589 let name_os = entry.file_name();
590 let Some(name) = name_os.to_str() else {
591 continue;
592 };
593 let is_match = name == "config.toml"
594 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
595 if !is_match {
596 continue;
597 }
598 let path = Utf8PathBuf::from_path_buf(entry.path())
599 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
600 files.push(path);
601 }
602 files.sort_by(|a, b| {
603 let an = a.file_name().unwrap_or("");
604 let bn = b.file_name().unwrap_or("");
605 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
606 });
607 Ok(files)
608}
609
610fn file_rank(name: &str) -> u8 {
611 match name {
612 "config.toml" => 0,
613 "config.local.toml" => 2,
614 _ => 1,
615 }
616}
617
618fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
621 for (k, v) in overlay {
622 match (base.remove(&k), v) {
623 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
624 deep_merge_table(&mut bt, ot);
625 base.insert(k, toml::Value::Table(bt));
626 }
627 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
628 ba.extend(oa);
629 base.insert(k, toml::Value::Array(ba));
630 }
631 (_, v) => {
632 base.insert(k, v);
633 }
634 }
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use tempfile::TempDir;
642
643 fn yui_vars(source: &Utf8Path) -> YuiVars {
644 YuiVars {
645 os: "linux".into(),
646 arch: "x86_64".into(),
647 host: "test".into(),
648 user: "u".into(),
649 source: source.to_string(),
650 }
651 }
652
653 fn write(tmp: &TempDir, name: &str, body: &str) {
654 std::fs::write(tmp.path().join(name), body).unwrap();
655 }
656
657 fn root(tmp: &TempDir) -> Utf8PathBuf {
658 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
659 }
660
661 #[test]
662 fn loads_single_file() {
663 let tmp = TempDir::new().unwrap();
664 write(
665 &tmp,
666 "config.toml",
667 r#"
668[vars]
669git_email = "a@example.com"
670
671[[mount.entry]]
672src = "home"
673dst = "/home/u"
674"#,
675 );
676 let r = root(&tmp);
677 let cfg = load(&r, &yui_vars(&r)).unwrap();
678 assert_eq!(
679 cfg.vars.get("git_email").unwrap().as_str(),
680 Some("a@example.com")
681 );
682 assert_eq!(cfg.mount.entry.len(), 1);
683 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
684 }
685
686 #[test]
687 fn local_overrides_base() {
688 let tmp = TempDir::new().unwrap();
689 write(
690 &tmp,
691 "config.toml",
692 r#"
693[vars]
694git_email = "a@example.com"
695work_mode = false
696"#,
697 );
698 write(
699 &tmp,
700 "config.local.toml",
701 r#"
702[vars]
703git_email = "b@work.com"
704"#,
705 );
706 let r = root(&tmp);
707 let cfg = load(&r, &yui_vars(&r)).unwrap();
708 assert_eq!(
709 cfg.vars.get("git_email").unwrap().as_str(),
710 Some("b@work.com")
711 );
712 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
714 }
715
716 #[test]
717 fn alphabetical_middle_files_apply_after_base_before_local() {
718 let tmp = TempDir::new().unwrap();
719 write(
720 &tmp,
721 "config.toml",
722 r#"[vars]
723val = "base""#,
724 );
725 write(
726 &tmp,
727 "config.aaa.toml",
728 r#"[vars]
729val = "aaa""#,
730 );
731 write(
732 &tmp,
733 "config.zzz.toml",
734 r#"[vars]
735val = "zzz""#,
736 );
737 write(
738 &tmp,
739 "config.local.toml",
740 r#"[vars]
741val = "local""#,
742 );
743 let r = root(&tmp);
744 let cfg = load(&r, &yui_vars(&r)).unwrap();
745 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
746 }
747
748 #[test]
749 fn yui_vars_available_in_render() {
750 let tmp = TempDir::new().unwrap();
751 write(
752 &tmp,
753 "config.toml",
754 r#"
755[[mount.entry]]
756src = "home"
757dst = "/{{ yui.os }}/dst"
758"#,
759 );
760 let r = root(&tmp);
761 let cfg = load(&r, &yui_vars(&r)).unwrap();
762 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
763 }
764
765 #[test]
766 fn mount_entries_append_across_files() {
767 let tmp = TempDir::new().unwrap();
768 write(
769 &tmp,
770 "config.toml",
771 r#"
772[[mount.entry]]
773src = "home"
774dst = "/h"
775"#,
776 );
777 write(
778 &tmp,
779 "config.local.toml",
780 r#"
781[[mount.entry]]
782src = "appdata"
783dst = "/a"
784"#,
785 );
786 let r = root(&tmp);
787 let cfg = load(&r, &yui_vars(&r)).unwrap();
788 assert_eq!(cfg.mount.entry.len(), 2);
789 }
790
791 #[test]
792 fn missing_config_errors() {
793 let tmp = TempDir::new().unwrap();
794 let r = root(&tmp);
795 let err = load(&r, &yui_vars(&r)).unwrap_err();
796 assert!(matches!(err, Error::Config(_)));
797 }
798
799 #[test]
800 fn defaults_apply_when_sections_absent() {
801 let tmp = TempDir::new().unwrap();
802 write(&tmp, "config.toml", "");
803 let r = root(&tmp);
804 let cfg = load(&r, &yui_vars(&r)).unwrap();
805 assert!(cfg.absorb.auto);
806 assert!(cfg.absorb.require_clean_git);
807 assert!(cfg.render.manage_gitignore);
808 assert_eq!(cfg.backup.dir, ".yui/backup");
809 assert_eq!(cfg.mount.marker_filename, ".yuilink");
810 }
811
812 #[test]
817 fn vars_visible_to_same_file_render() {
818 let tmp = TempDir::new().unwrap();
819 write(
820 &tmp,
821 "config.toml",
822 r#"
823[vars]
824home_root = "/custom/home"
825
826[[mount.entry]]
827src = "home"
828dst = "{{ vars.home_root }}"
829"#,
830 );
831 let r = root(&tmp);
832 let cfg = load(&r, &yui_vars(&r)).unwrap();
833 assert_eq!(cfg.mount.entry.len(), 1);
834 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
835 }
836
837 #[test]
841 fn vars_extract_skips_set_blocks() {
842 let tmp = TempDir::new().unwrap();
843 write(
844 &tmp,
845 "config.toml",
846 r#"
847{% set computed = "abc" %}
848[vars]
849plain = "real"
850
851[[mount.entry]]
852src = "home"
853dst = "{{ vars.plain }}"
854"#,
855 );
856 let r = root(&tmp);
857 let cfg = load(&r, &yui_vars(&r)).unwrap();
858 assert_eq!(cfg.mount.entry[0].dst, "real");
859 }
860
861 #[test]
864 fn vars_cross_reference_resolves_either_order() {
865 let tmp = TempDir::new().unwrap();
866 write(
867 &tmp,
868 "config.toml",
869 r#"
870[vars]
871a = "{{ vars.b }}"
872b = "raw"
873
874[[mount.entry]]
875src = "home"
876dst = "{{ vars.a }}"
877"#,
878 );
879 let r = root(&tmp);
880 let cfg = load(&r, &yui_vars(&r)).unwrap();
881 assert_eq!(cfg.mount.entry[0].dst, "raw");
882 }
883
884 #[test]
890 fn vars_cycle_does_not_loop_forever() {
891 let tmp = TempDir::new().unwrap();
892 write(
893 &tmp,
894 "config.toml",
895 r#"
896[vars]
897a = "{{ vars.b }}"
898b = "{{ vars.a }}"
899
900[[mount.entry]]
901src = "home"
902dst = "/anywhere"
903"#,
904 );
905 let r = root(&tmp);
906 let cfg = load(&r, &yui_vars(&r)).unwrap();
910 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
911 }
912
913 #[test]
921 fn hook_script_vars_survive_config_load_render_verbatim() {
922 let tmp = TempDir::new().unwrap();
923 write(
924 &tmp,
925 "config.toml",
926 r#"
927[[mount.entry]]
928src = "home"
929dst = "/home/u"
930
931[[hook]]
932name = "deno-build"
933script = ".yui/bin/build.ts"
934command = "deno"
935args = ["run", "-A", "{{ script_path }}"]
936when_run = "onchange"
937"#,
938 );
939 let r = root(&tmp);
940 let cfg = load(&r, &yui_vars(&r)).unwrap();
941 assert_eq!(cfg.hook.len(), 1);
942 assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
946 }
947}