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)]
121pub struct UiConfig {
122 #[serde(default)]
123 pub icons: IconsMode,
124 #[serde(default = "default_auto_update_check")]
129 pub auto_update_check: bool,
130 #[serde(default)]
134 pub update_check_interval: Option<String>,
135}
136
137impl Default for UiConfig {
138 fn default() -> Self {
139 Self {
140 icons: IconsMode::default(),
141 auto_update_check: default_auto_update_check(),
142 update_check_interval: None,
143 }
144 }
145}
146
147fn default_auto_update_check() -> bool {
148 true
149}
150
151#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
152#[serde(rename_all = "lowercase")]
153pub enum IconsMode {
154 #[default]
156 Unicode,
157 Nerd,
159 Ascii,
161}
162
163#[derive(Debug, Deserialize, Default)]
164pub struct LinkConfig {
165 #[serde(default)]
166 pub file_mode: FileLinkMode,
167 #[serde(default)]
168 pub dir_mode: DirLinkMode,
169}
170
171#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
172#[serde(rename_all = "lowercase")]
173pub enum FileLinkMode {
174 #[default]
175 Auto,
176 Symlink,
177 Hardlink,
178}
179
180#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
181#[serde(rename_all = "lowercase")]
182pub enum DirLinkMode {
183 #[default]
184 Auto,
185 Symlink,
186 Junction,
187}
188
189#[derive(Debug, Deserialize)]
190pub struct MountConfig {
191 #[serde(default)]
192 pub default_strategy: MountStrategy,
193 #[serde(default = "default_marker_filename")]
194 pub marker_filename: String,
195 #[serde(default)]
196 pub entry: Vec<MountEntry>,
197}
198
199impl Default for MountConfig {
200 fn default() -> Self {
201 Self {
202 default_strategy: MountStrategy::default(),
203 marker_filename: default_marker_filename(),
204 entry: Vec::new(),
205 }
206 }
207}
208
209fn default_marker_filename() -> String {
210 ".yuilink".to_string()
211}
212
213#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
214#[serde(rename_all = "kebab-case")]
215pub enum MountStrategy {
216 #[default]
217 Marker,
218 PerFile,
219}
220
221#[derive(Debug, Deserialize)]
222pub struct MountEntry {
223 pub src: Utf8PathBuf,
224 pub dst: String,
225 #[serde(default)]
226 pub when: Option<String>,
227 #[serde(default)]
228 pub strategy: Option<MountStrategy>,
229}
230
231#[derive(Debug, Deserialize)]
232pub struct AbsorbConfig {
233 #[serde(default = "default_true")]
234 pub auto: bool,
235 #[serde(default = "default_true")]
236 pub require_clean_git: bool,
237 #[serde(default)]
238 pub on_anomaly: AnomalyAction,
239}
240
241impl Default for AbsorbConfig {
242 fn default() -> Self {
243 Self {
244 auto: true,
245 require_clean_git: true,
246 on_anomaly: AnomalyAction::default(),
247 }
248 }
249}
250
251#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum AnomalyAction {
254 #[default]
255 Ask,
256 Skip,
257 Force,
258}
259
260#[derive(Debug, Deserialize)]
261pub struct RenderConfig {
262 #[serde(default = "default_true")]
263 pub manage_gitignore: bool,
264 #[serde(default)]
265 pub rule: Vec<RenderRule>,
266}
267
268impl Default for RenderConfig {
269 fn default() -> Self {
270 Self {
271 manage_gitignore: true,
272 rule: Vec::new(),
273 }
274 }
275}
276
277#[derive(Debug, Deserialize)]
278pub struct RenderRule {
279 pub r#match: String,
280 #[serde(default)]
281 pub when: Option<String>,
282}
283
284#[derive(Debug, Deserialize)]
285pub struct BackupConfig {
286 #[serde(default = "default_backup_dir")]
287 pub dir: String,
288 #[serde(default = "default_ts_format")]
289 pub timestamp_format: String,
290}
291
292impl Default for BackupConfig {
293 fn default() -> Self {
294 Self {
295 dir: default_backup_dir(),
296 timestamp_format: default_ts_format(),
297 }
298 }
299}
300
301fn default_backup_dir() -> String {
302 ".yui/backup".to_string()
303}
304
305#[derive(Debug, Clone, Deserialize)]
315pub struct SecretsConfig {
316 #[serde(default = "default_identity_path")]
320 pub identity: String,
321
322 #[serde(default)]
335 pub recipients: Vec<String>,
336
337 #[serde(default)]
343 pub vault: Option<VaultConfig>,
344}
345
346impl Default for SecretsConfig {
347 fn default() -> Self {
348 Self {
349 identity: default_identity_path(),
350 recipients: Vec::new(),
351 vault: None,
352 }
353 }
354}
355
356#[derive(Debug, Clone, Deserialize)]
368pub struct VaultConfig {
369 pub provider: VaultProvider,
371}
372
373pub const VAULT_ITEM_NAME: &str = "yui-x25519-identity";
379
380#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
381#[serde(rename_all = "lowercase")]
382pub enum VaultProvider {
383 Bitwarden,
384 #[serde(alias = "1password")]
385 OnePassword,
386}
387
388impl SecretsConfig {
389 pub fn enabled(&self) -> bool {
394 !self.recipients.is_empty()
395 }
396}
397
398fn default_identity_path() -> String {
399 "~/.config/yui/age.txt".to_string()
403}
404
405fn default_ts_format() -> String {
406 "%Y%m%d_%H%M%S%3f".to_string()
407}
408
409fn default_true() -> bool {
410 true
411}
412
413pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
415 let files = list_config_files(source)?;
416 if files.is_empty() {
417 return Err(Error::Config(format!(
418 "no config.toml / config.*.toml found at {source}"
419 )));
420 }
421
422 let mut engine = template::Engine::new();
423 let mut merged = toml::Table::new();
424 let mut vars_acc = toml::Table::new();
425
426 for file in &files {
427 let raw = std::fs::read_to_string(file)
428 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
429
430 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
436 deep_merge_table(&mut vars_acc, file_vars);
437 }
438 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
444
445 let ctx = template::config_render_context(yui, &vars_acc);
449 let rendered = engine.render(&raw, &ctx)?;
450 let parsed: toml::Table =
451 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
452
453 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
458 deep_merge_table(&mut vars_acc, file_vars.clone());
459 }
460 deep_merge_table(&mut merged, parsed);
461 }
462
463 let cfg: Config = toml::Value::Table(merged)
464 .try_into()
465 .map_err(|e| Error::Config(format!("schema: {e}")))?;
466 Ok(cfg)
467}
468
469fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
479 let mut in_vars = false;
480 let mut found_vars = false;
481 let mut lines: Vec<&str> = Vec::new();
482 for line in raw.lines() {
483 let trimmed = line.trim();
484 let header = trimmed.split('#').next().unwrap_or("").trim();
487 if header.starts_with("[") {
488 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
491 if normalized == "[vars]"
492 || normalized.starts_with("[vars.")
493 || normalized.starts_with("[vars[")
494 {
495 in_vars = true;
496 found_vars = true;
497 lines.push(line);
498 continue;
499 }
500 in_vars = false;
501 continue;
502 }
503 if trimmed.starts_with("{%") {
508 continue;
509 }
510 if in_vars {
511 lines.push(line);
512 }
513 }
514 if !found_vars {
515 return Ok(None);
516 }
517 let extracted = lines.join("\n");
518 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
519 Error::Config(format!(
520 "pre-extract [vars] from {file}: {e} \
521 (the [vars] block must be parseable on its own — \
522 move computed values into a `set` block above the section)"
523 ))
524 })?;
525 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
526 Ok(Some(vars.clone()))
527 } else {
528 Ok(None)
529 }
530}
531
532const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
538
539fn resolve_vars_refs(
543 vars: &mut toml::Table,
544 yui: &YuiVars,
545 engine: &mut template::Engine,
546) -> Result<()> {
547 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
548 let ctx = template::config_render_context(yui, vars);
553 let mut changed = false;
554 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
555 if !changed {
556 return Ok(());
557 }
558 }
559 Ok(())
564}
565
566fn render_strings_in_table(
567 table: &mut toml::Table,
568 engine: &mut template::Engine,
569 ctx: &tera::Context,
570 changed: &mut bool,
571) -> Result<()> {
572 for (_k, value) in table.iter_mut() {
573 render_strings_in_value(value, engine, ctx, changed)?;
574 }
575 Ok(())
576}
577
578fn render_strings_in_value(
579 value: &mut toml::Value,
580 engine: &mut template::Engine,
581 ctx: &tera::Context,
582 changed: &mut bool,
583) -> Result<()> {
584 match value {
585 toml::Value::String(s) => {
586 if !s.contains("{{") && !s.contains("{%") {
587 return Ok(());
588 }
589 let rendered = engine.render(s.as_str(), ctx)?;
590 if rendered != *s {
591 *s = rendered;
592 *changed = true;
593 }
594 }
595 toml::Value::Table(t) => {
596 render_strings_in_table(t, engine, ctx, changed)?;
597 }
598 toml::Value::Array(arr) => {
599 for v in arr.iter_mut() {
600 render_strings_in_value(v, engine, ctx, changed)?;
601 }
602 }
603 _ => {}
604 }
605 Ok(())
606}
607
608fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
613 let entries =
614 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
615 let mut files: Vec<Utf8PathBuf> = Vec::new();
616 for entry in entries {
617 let entry = entry.map_err(Error::Io)?;
618 let name_os = entry.file_name();
619 let Some(name) = name_os.to_str() else {
620 continue;
621 };
622 let is_match = name == "config.toml"
623 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
624 if !is_match {
625 continue;
626 }
627 let path = Utf8PathBuf::from_path_buf(entry.path())
628 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
629 files.push(path);
630 }
631 files.sort_by(|a, b| {
632 let an = a.file_name().unwrap_or("");
633 let bn = b.file_name().unwrap_or("");
634 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
635 });
636 Ok(files)
637}
638
639fn file_rank(name: &str) -> u8 {
640 match name {
641 "config.toml" => 0,
642 "config.local.toml" => 2,
643 _ => 1,
644 }
645}
646
647fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
650 for (k, v) in overlay {
651 match (base.remove(&k), v) {
652 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
653 deep_merge_table(&mut bt, ot);
654 base.insert(k, toml::Value::Table(bt));
655 }
656 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
657 ba.extend(oa);
658 base.insert(k, toml::Value::Array(ba));
659 }
660 (_, v) => {
661 base.insert(k, v);
662 }
663 }
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use tempfile::TempDir;
671
672 fn yui_vars(source: &Utf8Path) -> YuiVars {
673 YuiVars {
674 os: "linux".into(),
675 arch: "x86_64".into(),
676 host: "test".into(),
677 user: "u".into(),
678 source: source.to_string(),
679 }
680 }
681
682 fn write(tmp: &TempDir, name: &str, body: &str) {
683 std::fs::write(tmp.path().join(name), body).unwrap();
684 }
685
686 fn root(tmp: &TempDir) -> Utf8PathBuf {
687 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
688 }
689
690 #[test]
691 fn loads_single_file() {
692 let tmp = TempDir::new().unwrap();
693 write(
694 &tmp,
695 "config.toml",
696 r#"
697[vars]
698git_email = "a@example.com"
699
700[[mount.entry]]
701src = "home"
702dst = "/home/u"
703"#,
704 );
705 let r = root(&tmp);
706 let cfg = load(&r, &yui_vars(&r)).unwrap();
707 assert_eq!(
708 cfg.vars.get("git_email").unwrap().as_str(),
709 Some("a@example.com")
710 );
711 assert_eq!(cfg.mount.entry.len(), 1);
712 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
713 }
714
715 #[test]
716 fn local_overrides_base() {
717 let tmp = TempDir::new().unwrap();
718 write(
719 &tmp,
720 "config.toml",
721 r#"
722[vars]
723git_email = "a@example.com"
724work_mode = false
725"#,
726 );
727 write(
728 &tmp,
729 "config.local.toml",
730 r#"
731[vars]
732git_email = "b@work.com"
733"#,
734 );
735 let r = root(&tmp);
736 let cfg = load(&r, &yui_vars(&r)).unwrap();
737 assert_eq!(
738 cfg.vars.get("git_email").unwrap().as_str(),
739 Some("b@work.com")
740 );
741 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
743 }
744
745 #[test]
746 fn alphabetical_middle_files_apply_after_base_before_local() {
747 let tmp = TempDir::new().unwrap();
748 write(
749 &tmp,
750 "config.toml",
751 r#"[vars]
752val = "base""#,
753 );
754 write(
755 &tmp,
756 "config.aaa.toml",
757 r#"[vars]
758val = "aaa""#,
759 );
760 write(
761 &tmp,
762 "config.zzz.toml",
763 r#"[vars]
764val = "zzz""#,
765 );
766 write(
767 &tmp,
768 "config.local.toml",
769 r#"[vars]
770val = "local""#,
771 );
772 let r = root(&tmp);
773 let cfg = load(&r, &yui_vars(&r)).unwrap();
774 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
775 }
776
777 #[test]
778 fn yui_vars_available_in_render() {
779 let tmp = TempDir::new().unwrap();
780 write(
781 &tmp,
782 "config.toml",
783 r#"
784[[mount.entry]]
785src = "home"
786dst = "/{{ yui.os }}/dst"
787"#,
788 );
789 let r = root(&tmp);
790 let cfg = load(&r, &yui_vars(&r)).unwrap();
791 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
792 }
793
794 #[test]
795 fn mount_entries_append_across_files() {
796 let tmp = TempDir::new().unwrap();
797 write(
798 &tmp,
799 "config.toml",
800 r#"
801[[mount.entry]]
802src = "home"
803dst = "/h"
804"#,
805 );
806 write(
807 &tmp,
808 "config.local.toml",
809 r#"
810[[mount.entry]]
811src = "appdata"
812dst = "/a"
813"#,
814 );
815 let r = root(&tmp);
816 let cfg = load(&r, &yui_vars(&r)).unwrap();
817 assert_eq!(cfg.mount.entry.len(), 2);
818 }
819
820 #[test]
821 fn missing_config_errors() {
822 let tmp = TempDir::new().unwrap();
823 let r = root(&tmp);
824 let err = load(&r, &yui_vars(&r)).unwrap_err();
825 assert!(matches!(err, Error::Config(_)));
826 }
827
828 #[test]
829 fn defaults_apply_when_sections_absent() {
830 let tmp = TempDir::new().unwrap();
831 write(&tmp, "config.toml", "");
832 let r = root(&tmp);
833 let cfg = load(&r, &yui_vars(&r)).unwrap();
834 assert!(cfg.absorb.auto);
835 assert!(cfg.absorb.require_clean_git);
836 assert!(cfg.render.manage_gitignore);
837 assert_eq!(cfg.backup.dir, ".yui/backup");
838 assert_eq!(cfg.mount.marker_filename, ".yuilink");
839 }
840
841 #[test]
846 fn vars_visible_to_same_file_render() {
847 let tmp = TempDir::new().unwrap();
848 write(
849 &tmp,
850 "config.toml",
851 r#"
852[vars]
853home_root = "/custom/home"
854
855[[mount.entry]]
856src = "home"
857dst = "{{ vars.home_root }}"
858"#,
859 );
860 let r = root(&tmp);
861 let cfg = load(&r, &yui_vars(&r)).unwrap();
862 assert_eq!(cfg.mount.entry.len(), 1);
863 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
864 }
865
866 #[test]
870 fn vars_extract_skips_set_blocks() {
871 let tmp = TempDir::new().unwrap();
872 write(
873 &tmp,
874 "config.toml",
875 r#"
876{% set computed = "abc" %}
877[vars]
878plain = "real"
879
880[[mount.entry]]
881src = "home"
882dst = "{{ vars.plain }}"
883"#,
884 );
885 let r = root(&tmp);
886 let cfg = load(&r, &yui_vars(&r)).unwrap();
887 assert_eq!(cfg.mount.entry[0].dst, "real");
888 }
889
890 #[test]
893 fn vars_cross_reference_resolves_either_order() {
894 let tmp = TempDir::new().unwrap();
895 write(
896 &tmp,
897 "config.toml",
898 r#"
899[vars]
900a = "{{ vars.b }}"
901b = "raw"
902
903[[mount.entry]]
904src = "home"
905dst = "{{ vars.a }}"
906"#,
907 );
908 let r = root(&tmp);
909 let cfg = load(&r, &yui_vars(&r)).unwrap();
910 assert_eq!(cfg.mount.entry[0].dst, "raw");
911 }
912
913 #[test]
919 fn vars_cycle_does_not_loop_forever() {
920 let tmp = TempDir::new().unwrap();
921 write(
922 &tmp,
923 "config.toml",
924 r#"
925[vars]
926a = "{{ vars.b }}"
927b = "{{ vars.a }}"
928
929[[mount.entry]]
930src = "home"
931dst = "/anywhere"
932"#,
933 );
934 let r = root(&tmp);
935 let cfg = load(&r, &yui_vars(&r)).unwrap();
939 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
940 }
941
942 #[test]
950 fn hook_script_vars_survive_config_load_render_verbatim() {
951 let tmp = TempDir::new().unwrap();
952 write(
953 &tmp,
954 "config.toml",
955 r#"
956[[mount.entry]]
957src = "home"
958dst = "/home/u"
959
960[[hook]]
961name = "deno-build"
962script = ".yui/bin/build.ts"
963command = "deno"
964args = ["run", "-A", "{{ script_path }}"]
965when_run = "onchange"
966"#,
967 );
968 let r = root(&tmp);
969 let cfg = load(&r, &yui_vars(&r)).unwrap();
970 assert_eq!(cfg.hook.len(), 1);
971 assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
975 }
976}