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}
347
348pub const VAULT_ITEM_NAME: &str = "yui-x25519-identity";
354
355#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
356#[serde(rename_all = "lowercase")]
357pub enum VaultProvider {
358 Bitwarden,
359 #[serde(alias = "1password")]
360 OnePassword,
361}
362
363impl SecretsConfig {
364 pub fn enabled(&self) -> bool {
369 !self.recipients.is_empty()
370 }
371}
372
373fn default_identity_path() -> String {
374 "~/.config/yui/age.txt".to_string()
378}
379
380fn default_ts_format() -> String {
381 "%Y%m%d_%H%M%S%3f".to_string()
382}
383
384fn default_true() -> bool {
385 true
386}
387
388pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
390 let files = list_config_files(source)?;
391 if files.is_empty() {
392 return Err(Error::Config(format!(
393 "no config.toml / config.*.toml found at {source}"
394 )));
395 }
396
397 let mut engine = template::Engine::new();
398 let mut merged = toml::Table::new();
399 let mut vars_acc = toml::Table::new();
400
401 for file in &files {
402 let raw = std::fs::read_to_string(file)
403 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
404
405 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
411 deep_merge_table(&mut vars_acc, file_vars);
412 }
413 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
419
420 let ctx = template::config_render_context(yui, &vars_acc);
424 let rendered = engine.render(&raw, &ctx)?;
425 let parsed: toml::Table =
426 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
427
428 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
433 deep_merge_table(&mut vars_acc, file_vars.clone());
434 }
435 deep_merge_table(&mut merged, parsed);
436 }
437
438 let cfg: Config = toml::Value::Table(merged)
439 .try_into()
440 .map_err(|e| Error::Config(format!("schema: {e}")))?;
441 Ok(cfg)
442}
443
444fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
454 let mut in_vars = false;
455 let mut found_vars = false;
456 let mut lines: Vec<&str> = Vec::new();
457 for line in raw.lines() {
458 let trimmed = line.trim();
459 let header = trimmed.split('#').next().unwrap_or("").trim();
462 if header.starts_with("[") {
463 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
466 if normalized == "[vars]"
467 || normalized.starts_with("[vars.")
468 || normalized.starts_with("[vars[")
469 {
470 in_vars = true;
471 found_vars = true;
472 lines.push(line);
473 continue;
474 }
475 in_vars = false;
476 continue;
477 }
478 if trimmed.starts_with("{%") {
483 continue;
484 }
485 if in_vars {
486 lines.push(line);
487 }
488 }
489 if !found_vars {
490 return Ok(None);
491 }
492 let extracted = lines.join("\n");
493 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
494 Error::Config(format!(
495 "pre-extract [vars] from {file}: {e} \
496 (the [vars] block must be parseable on its own — \
497 move computed values into a `set` block above the section)"
498 ))
499 })?;
500 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
501 Ok(Some(vars.clone()))
502 } else {
503 Ok(None)
504 }
505}
506
507const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
513
514fn resolve_vars_refs(
518 vars: &mut toml::Table,
519 yui: &YuiVars,
520 engine: &mut template::Engine,
521) -> Result<()> {
522 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
523 let ctx = template::config_render_context(yui, vars);
528 let mut changed = false;
529 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
530 if !changed {
531 return Ok(());
532 }
533 }
534 Ok(())
539}
540
541fn render_strings_in_table(
542 table: &mut toml::Table,
543 engine: &mut template::Engine,
544 ctx: &tera::Context,
545 changed: &mut bool,
546) -> Result<()> {
547 for (_k, value) in table.iter_mut() {
548 render_strings_in_value(value, engine, ctx, changed)?;
549 }
550 Ok(())
551}
552
553fn render_strings_in_value(
554 value: &mut toml::Value,
555 engine: &mut template::Engine,
556 ctx: &tera::Context,
557 changed: &mut bool,
558) -> Result<()> {
559 match value {
560 toml::Value::String(s) => {
561 if !s.contains("{{") && !s.contains("{%") {
562 return Ok(());
563 }
564 let rendered = engine.render(s.as_str(), ctx)?;
565 if rendered != *s {
566 *s = rendered;
567 *changed = true;
568 }
569 }
570 toml::Value::Table(t) => {
571 render_strings_in_table(t, engine, ctx, changed)?;
572 }
573 toml::Value::Array(arr) => {
574 for v in arr.iter_mut() {
575 render_strings_in_value(v, engine, ctx, changed)?;
576 }
577 }
578 _ => {}
579 }
580 Ok(())
581}
582
583fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
588 let entries =
589 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
590 let mut files: Vec<Utf8PathBuf> = Vec::new();
591 for entry in entries {
592 let entry = entry.map_err(Error::Io)?;
593 let name_os = entry.file_name();
594 let Some(name) = name_os.to_str() else {
595 continue;
596 };
597 let is_match = name == "config.toml"
598 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
599 if !is_match {
600 continue;
601 }
602 let path = Utf8PathBuf::from_path_buf(entry.path())
603 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
604 files.push(path);
605 }
606 files.sort_by(|a, b| {
607 let an = a.file_name().unwrap_or("");
608 let bn = b.file_name().unwrap_or("");
609 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
610 });
611 Ok(files)
612}
613
614fn file_rank(name: &str) -> u8 {
615 match name {
616 "config.toml" => 0,
617 "config.local.toml" => 2,
618 _ => 1,
619 }
620}
621
622fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
625 for (k, v) in overlay {
626 match (base.remove(&k), v) {
627 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
628 deep_merge_table(&mut bt, ot);
629 base.insert(k, toml::Value::Table(bt));
630 }
631 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
632 ba.extend(oa);
633 base.insert(k, toml::Value::Array(ba));
634 }
635 (_, v) => {
636 base.insert(k, v);
637 }
638 }
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use tempfile::TempDir;
646
647 fn yui_vars(source: &Utf8Path) -> YuiVars {
648 YuiVars {
649 os: "linux".into(),
650 arch: "x86_64".into(),
651 host: "test".into(),
652 user: "u".into(),
653 source: source.to_string(),
654 }
655 }
656
657 fn write(tmp: &TempDir, name: &str, body: &str) {
658 std::fs::write(tmp.path().join(name), body).unwrap();
659 }
660
661 fn root(tmp: &TempDir) -> Utf8PathBuf {
662 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
663 }
664
665 #[test]
666 fn loads_single_file() {
667 let tmp = TempDir::new().unwrap();
668 write(
669 &tmp,
670 "config.toml",
671 r#"
672[vars]
673git_email = "a@example.com"
674
675[[mount.entry]]
676src = "home"
677dst = "/home/u"
678"#,
679 );
680 let r = root(&tmp);
681 let cfg = load(&r, &yui_vars(&r)).unwrap();
682 assert_eq!(
683 cfg.vars.get("git_email").unwrap().as_str(),
684 Some("a@example.com")
685 );
686 assert_eq!(cfg.mount.entry.len(), 1);
687 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
688 }
689
690 #[test]
691 fn local_overrides_base() {
692 let tmp = TempDir::new().unwrap();
693 write(
694 &tmp,
695 "config.toml",
696 r#"
697[vars]
698git_email = "a@example.com"
699work_mode = false
700"#,
701 );
702 write(
703 &tmp,
704 "config.local.toml",
705 r#"
706[vars]
707git_email = "b@work.com"
708"#,
709 );
710 let r = root(&tmp);
711 let cfg = load(&r, &yui_vars(&r)).unwrap();
712 assert_eq!(
713 cfg.vars.get("git_email").unwrap().as_str(),
714 Some("b@work.com")
715 );
716 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
718 }
719
720 #[test]
721 fn alphabetical_middle_files_apply_after_base_before_local() {
722 let tmp = TempDir::new().unwrap();
723 write(
724 &tmp,
725 "config.toml",
726 r#"[vars]
727val = "base""#,
728 );
729 write(
730 &tmp,
731 "config.aaa.toml",
732 r#"[vars]
733val = "aaa""#,
734 );
735 write(
736 &tmp,
737 "config.zzz.toml",
738 r#"[vars]
739val = "zzz""#,
740 );
741 write(
742 &tmp,
743 "config.local.toml",
744 r#"[vars]
745val = "local""#,
746 );
747 let r = root(&tmp);
748 let cfg = load(&r, &yui_vars(&r)).unwrap();
749 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
750 }
751
752 #[test]
753 fn yui_vars_available_in_render() {
754 let tmp = TempDir::new().unwrap();
755 write(
756 &tmp,
757 "config.toml",
758 r#"
759[[mount.entry]]
760src = "home"
761dst = "/{{ yui.os }}/dst"
762"#,
763 );
764 let r = root(&tmp);
765 let cfg = load(&r, &yui_vars(&r)).unwrap();
766 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
767 }
768
769 #[test]
770 fn mount_entries_append_across_files() {
771 let tmp = TempDir::new().unwrap();
772 write(
773 &tmp,
774 "config.toml",
775 r#"
776[[mount.entry]]
777src = "home"
778dst = "/h"
779"#,
780 );
781 write(
782 &tmp,
783 "config.local.toml",
784 r#"
785[[mount.entry]]
786src = "appdata"
787dst = "/a"
788"#,
789 );
790 let r = root(&tmp);
791 let cfg = load(&r, &yui_vars(&r)).unwrap();
792 assert_eq!(cfg.mount.entry.len(), 2);
793 }
794
795 #[test]
796 fn missing_config_errors() {
797 let tmp = TempDir::new().unwrap();
798 let r = root(&tmp);
799 let err = load(&r, &yui_vars(&r)).unwrap_err();
800 assert!(matches!(err, Error::Config(_)));
801 }
802
803 #[test]
804 fn defaults_apply_when_sections_absent() {
805 let tmp = TempDir::new().unwrap();
806 write(&tmp, "config.toml", "");
807 let r = root(&tmp);
808 let cfg = load(&r, &yui_vars(&r)).unwrap();
809 assert!(cfg.absorb.auto);
810 assert!(cfg.absorb.require_clean_git);
811 assert!(cfg.render.manage_gitignore);
812 assert_eq!(cfg.backup.dir, ".yui/backup");
813 assert_eq!(cfg.mount.marker_filename, ".yuilink");
814 }
815
816 #[test]
821 fn vars_visible_to_same_file_render() {
822 let tmp = TempDir::new().unwrap();
823 write(
824 &tmp,
825 "config.toml",
826 r#"
827[vars]
828home_root = "/custom/home"
829
830[[mount.entry]]
831src = "home"
832dst = "{{ vars.home_root }}"
833"#,
834 );
835 let r = root(&tmp);
836 let cfg = load(&r, &yui_vars(&r)).unwrap();
837 assert_eq!(cfg.mount.entry.len(), 1);
838 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
839 }
840
841 #[test]
845 fn vars_extract_skips_set_blocks() {
846 let tmp = TempDir::new().unwrap();
847 write(
848 &tmp,
849 "config.toml",
850 r#"
851{% set computed = "abc" %}
852[vars]
853plain = "real"
854
855[[mount.entry]]
856src = "home"
857dst = "{{ vars.plain }}"
858"#,
859 );
860 let r = root(&tmp);
861 let cfg = load(&r, &yui_vars(&r)).unwrap();
862 assert_eq!(cfg.mount.entry[0].dst, "real");
863 }
864
865 #[test]
868 fn vars_cross_reference_resolves_either_order() {
869 let tmp = TempDir::new().unwrap();
870 write(
871 &tmp,
872 "config.toml",
873 r#"
874[vars]
875a = "{{ vars.b }}"
876b = "raw"
877
878[[mount.entry]]
879src = "home"
880dst = "{{ vars.a }}"
881"#,
882 );
883 let r = root(&tmp);
884 let cfg = load(&r, &yui_vars(&r)).unwrap();
885 assert_eq!(cfg.mount.entry[0].dst, "raw");
886 }
887
888 #[test]
894 fn vars_cycle_does_not_loop_forever() {
895 let tmp = TempDir::new().unwrap();
896 write(
897 &tmp,
898 "config.toml",
899 r#"
900[vars]
901a = "{{ vars.b }}"
902b = "{{ vars.a }}"
903
904[[mount.entry]]
905src = "home"
906dst = "/anywhere"
907"#,
908 );
909 let r = root(&tmp);
910 let cfg = load(&r, &yui_vars(&r)).unwrap();
914 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
915 }
916
917 #[test]
925 fn hook_script_vars_survive_config_load_render_verbatim() {
926 let tmp = TempDir::new().unwrap();
927 write(
928 &tmp,
929 "config.toml",
930 r#"
931[[mount.entry]]
932src = "home"
933dst = "/home/u"
934
935[[hook]]
936name = "deno-build"
937script = ".yui/bin/build.ts"
938command = "deno"
939args = ["run", "-A", "{{ script_path }}"]
940when_run = "onchange"
941"#,
942 );
943 let r = root(&tmp);
944 let cfg = load(&r, &yui_vars(&r)).unwrap();
945 assert_eq!(cfg.hook.len(), 1);
946 assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
950 }
951}