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)]
302 pub recipients: Vec<String>,
303
304 #[serde(default)]
310 pub passkey_wrapped: Option<String>,
311
312 #[serde(default)]
316 pub passkey_recipients: Vec<String>,
317
318 #[serde(default)]
324 pub passkey_identities: Option<String>,
325}
326
327impl Default for SecretsConfig {
328 fn default() -> Self {
329 Self {
330 identity: default_identity_path(),
331 recipients: Vec::new(),
332 passkey_wrapped: None,
333 passkey_recipients: Vec::new(),
334 passkey_identities: None,
335 }
336 }
337}
338
339impl SecretsConfig {
340 pub fn enabled(&self) -> bool {
345 !self.recipients.is_empty()
346 }
347}
348
349fn default_identity_path() -> String {
350 "~/.config/yui/age.txt".to_string()
354}
355
356fn default_ts_format() -> String {
357 "%Y%m%d_%H%M%S%3f".to_string()
358}
359
360fn default_true() -> bool {
361 true
362}
363
364pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
366 let files = list_config_files(source)?;
367 if files.is_empty() {
368 return Err(Error::Config(format!(
369 "no config.toml / config.*.toml found at {source}"
370 )));
371 }
372
373 let mut engine = template::Engine::new();
374 let mut merged = toml::Table::new();
375 let mut vars_acc = toml::Table::new();
376
377 for file in &files {
378 let raw = std::fs::read_to_string(file)
379 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
380
381 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
387 deep_merge_table(&mut vars_acc, file_vars);
388 }
389 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
395
396 let ctx = template::config_render_context(yui, &vars_acc);
400 let rendered = engine.render(&raw, &ctx)?;
401 let parsed: toml::Table =
402 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
403
404 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
409 deep_merge_table(&mut vars_acc, file_vars.clone());
410 }
411 deep_merge_table(&mut merged, parsed);
412 }
413
414 let cfg: Config = toml::Value::Table(merged)
415 .try_into()
416 .map_err(|e| Error::Config(format!("schema: {e}")))?;
417 Ok(cfg)
418}
419
420fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
430 let mut in_vars = false;
431 let mut found_vars = false;
432 let mut lines: Vec<&str> = Vec::new();
433 for line in raw.lines() {
434 let trimmed = line.trim();
435 let header = trimmed.split('#').next().unwrap_or("").trim();
438 if header.starts_with("[") {
439 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
442 if normalized == "[vars]"
443 || normalized.starts_with("[vars.")
444 || normalized.starts_with("[vars[")
445 {
446 in_vars = true;
447 found_vars = true;
448 lines.push(line);
449 continue;
450 }
451 in_vars = false;
452 continue;
453 }
454 if trimmed.starts_with("{%") {
459 continue;
460 }
461 if in_vars {
462 lines.push(line);
463 }
464 }
465 if !found_vars {
466 return Ok(None);
467 }
468 let extracted = lines.join("\n");
469 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
470 Error::Config(format!(
471 "pre-extract [vars] from {file}: {e} \
472 (the [vars] block must be parseable on its own — \
473 move computed values into a `set` block above the section)"
474 ))
475 })?;
476 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
477 Ok(Some(vars.clone()))
478 } else {
479 Ok(None)
480 }
481}
482
483const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
489
490fn resolve_vars_refs(
494 vars: &mut toml::Table,
495 yui: &YuiVars,
496 engine: &mut template::Engine,
497) -> Result<()> {
498 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
499 let ctx = template::config_render_context(yui, vars);
504 let mut changed = false;
505 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
506 if !changed {
507 return Ok(());
508 }
509 }
510 Ok(())
515}
516
517fn render_strings_in_table(
518 table: &mut toml::Table,
519 engine: &mut template::Engine,
520 ctx: &tera::Context,
521 changed: &mut bool,
522) -> Result<()> {
523 for (_k, value) in table.iter_mut() {
524 render_strings_in_value(value, engine, ctx, changed)?;
525 }
526 Ok(())
527}
528
529fn render_strings_in_value(
530 value: &mut toml::Value,
531 engine: &mut template::Engine,
532 ctx: &tera::Context,
533 changed: &mut bool,
534) -> Result<()> {
535 match value {
536 toml::Value::String(s) => {
537 if !s.contains("{{") && !s.contains("{%") {
538 return Ok(());
539 }
540 let rendered = engine.render(s.as_str(), ctx)?;
541 if rendered != *s {
542 *s = rendered;
543 *changed = true;
544 }
545 }
546 toml::Value::Table(t) => {
547 render_strings_in_table(t, engine, ctx, changed)?;
548 }
549 toml::Value::Array(arr) => {
550 for v in arr.iter_mut() {
551 render_strings_in_value(v, engine, ctx, changed)?;
552 }
553 }
554 _ => {}
555 }
556 Ok(())
557}
558
559fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
564 let entries =
565 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
566 let mut files: Vec<Utf8PathBuf> = Vec::new();
567 for entry in entries {
568 let entry = entry.map_err(Error::Io)?;
569 let name_os = entry.file_name();
570 let Some(name) = name_os.to_str() else {
571 continue;
572 };
573 let is_match = name == "config.toml"
574 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
575 if !is_match {
576 continue;
577 }
578 let path = Utf8PathBuf::from_path_buf(entry.path())
579 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
580 files.push(path);
581 }
582 files.sort_by(|a, b| {
583 let an = a.file_name().unwrap_or("");
584 let bn = b.file_name().unwrap_or("");
585 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
586 });
587 Ok(files)
588}
589
590fn file_rank(name: &str) -> u8 {
591 match name {
592 "config.toml" => 0,
593 "config.local.toml" => 2,
594 _ => 1,
595 }
596}
597
598fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
601 for (k, v) in overlay {
602 match (base.remove(&k), v) {
603 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
604 deep_merge_table(&mut bt, ot);
605 base.insert(k, toml::Value::Table(bt));
606 }
607 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
608 ba.extend(oa);
609 base.insert(k, toml::Value::Array(ba));
610 }
611 (_, v) => {
612 base.insert(k, v);
613 }
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use tempfile::TempDir;
622
623 fn yui_vars(source: &Utf8Path) -> YuiVars {
624 YuiVars {
625 os: "linux".into(),
626 arch: "x86_64".into(),
627 host: "test".into(),
628 user: "u".into(),
629 source: source.to_string(),
630 }
631 }
632
633 fn write(tmp: &TempDir, name: &str, body: &str) {
634 std::fs::write(tmp.path().join(name), body).unwrap();
635 }
636
637 fn root(tmp: &TempDir) -> Utf8PathBuf {
638 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
639 }
640
641 #[test]
642 fn loads_single_file() {
643 let tmp = TempDir::new().unwrap();
644 write(
645 &tmp,
646 "config.toml",
647 r#"
648[vars]
649git_email = "a@example.com"
650
651[[mount.entry]]
652src = "home"
653dst = "/home/u"
654"#,
655 );
656 let r = root(&tmp);
657 let cfg = load(&r, &yui_vars(&r)).unwrap();
658 assert_eq!(
659 cfg.vars.get("git_email").unwrap().as_str(),
660 Some("a@example.com")
661 );
662 assert_eq!(cfg.mount.entry.len(), 1);
663 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
664 }
665
666 #[test]
667 fn local_overrides_base() {
668 let tmp = TempDir::new().unwrap();
669 write(
670 &tmp,
671 "config.toml",
672 r#"
673[vars]
674git_email = "a@example.com"
675work_mode = false
676"#,
677 );
678 write(
679 &tmp,
680 "config.local.toml",
681 r#"
682[vars]
683git_email = "b@work.com"
684"#,
685 );
686 let r = root(&tmp);
687 let cfg = load(&r, &yui_vars(&r)).unwrap();
688 assert_eq!(
689 cfg.vars.get("git_email").unwrap().as_str(),
690 Some("b@work.com")
691 );
692 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
694 }
695
696 #[test]
697 fn alphabetical_middle_files_apply_after_base_before_local() {
698 let tmp = TempDir::new().unwrap();
699 write(
700 &tmp,
701 "config.toml",
702 r#"[vars]
703val = "base""#,
704 );
705 write(
706 &tmp,
707 "config.aaa.toml",
708 r#"[vars]
709val = "aaa""#,
710 );
711 write(
712 &tmp,
713 "config.zzz.toml",
714 r#"[vars]
715val = "zzz""#,
716 );
717 write(
718 &tmp,
719 "config.local.toml",
720 r#"[vars]
721val = "local""#,
722 );
723 let r = root(&tmp);
724 let cfg = load(&r, &yui_vars(&r)).unwrap();
725 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
726 }
727
728 #[test]
729 fn yui_vars_available_in_render() {
730 let tmp = TempDir::new().unwrap();
731 write(
732 &tmp,
733 "config.toml",
734 r#"
735[[mount.entry]]
736src = "home"
737dst = "/{{ yui.os }}/dst"
738"#,
739 );
740 let r = root(&tmp);
741 let cfg = load(&r, &yui_vars(&r)).unwrap();
742 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
743 }
744
745 #[test]
746 fn mount_entries_append_across_files() {
747 let tmp = TempDir::new().unwrap();
748 write(
749 &tmp,
750 "config.toml",
751 r#"
752[[mount.entry]]
753src = "home"
754dst = "/h"
755"#,
756 );
757 write(
758 &tmp,
759 "config.local.toml",
760 r#"
761[[mount.entry]]
762src = "appdata"
763dst = "/a"
764"#,
765 );
766 let r = root(&tmp);
767 let cfg = load(&r, &yui_vars(&r)).unwrap();
768 assert_eq!(cfg.mount.entry.len(), 2);
769 }
770
771 #[test]
772 fn missing_config_errors() {
773 let tmp = TempDir::new().unwrap();
774 let r = root(&tmp);
775 let err = load(&r, &yui_vars(&r)).unwrap_err();
776 assert!(matches!(err, Error::Config(_)));
777 }
778
779 #[test]
780 fn defaults_apply_when_sections_absent() {
781 let tmp = TempDir::new().unwrap();
782 write(&tmp, "config.toml", "");
783 let r = root(&tmp);
784 let cfg = load(&r, &yui_vars(&r)).unwrap();
785 assert!(cfg.absorb.auto);
786 assert!(cfg.absorb.require_clean_git);
787 assert!(cfg.render.manage_gitignore);
788 assert_eq!(cfg.backup.dir, ".yui/backup");
789 assert_eq!(cfg.mount.marker_filename, ".yuilink");
790 }
791
792 #[test]
797 fn vars_visible_to_same_file_render() {
798 let tmp = TempDir::new().unwrap();
799 write(
800 &tmp,
801 "config.toml",
802 r#"
803[vars]
804home_root = "/custom/home"
805
806[[mount.entry]]
807src = "home"
808dst = "{{ vars.home_root }}"
809"#,
810 );
811 let r = root(&tmp);
812 let cfg = load(&r, &yui_vars(&r)).unwrap();
813 assert_eq!(cfg.mount.entry.len(), 1);
814 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
815 }
816
817 #[test]
821 fn vars_extract_skips_set_blocks() {
822 let tmp = TempDir::new().unwrap();
823 write(
824 &tmp,
825 "config.toml",
826 r#"
827{% set computed = "abc" %}
828[vars]
829plain = "real"
830
831[[mount.entry]]
832src = "home"
833dst = "{{ vars.plain }}"
834"#,
835 );
836 let r = root(&tmp);
837 let cfg = load(&r, &yui_vars(&r)).unwrap();
838 assert_eq!(cfg.mount.entry[0].dst, "real");
839 }
840
841 #[test]
844 fn vars_cross_reference_resolves_either_order() {
845 let tmp = TempDir::new().unwrap();
846 write(
847 &tmp,
848 "config.toml",
849 r#"
850[vars]
851a = "{{ vars.b }}"
852b = "raw"
853
854[[mount.entry]]
855src = "home"
856dst = "{{ vars.a }}"
857"#,
858 );
859 let r = root(&tmp);
860 let cfg = load(&r, &yui_vars(&r)).unwrap();
861 assert_eq!(cfg.mount.entry[0].dst, "raw");
862 }
863
864 #[test]
870 fn vars_cycle_does_not_loop_forever() {
871 let tmp = TempDir::new().unwrap();
872 write(
873 &tmp,
874 "config.toml",
875 r#"
876[vars]
877a = "{{ vars.b }}"
878b = "{{ vars.a }}"
879
880[[mount.entry]]
881src = "home"
882dst = "/anywhere"
883"#,
884 );
885 let r = root(&tmp);
886 let cfg = load(&r, &yui_vars(&r)).unwrap();
890 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
891 }
892
893 #[test]
901 fn hook_script_vars_survive_config_load_render_verbatim() {
902 let tmp = TempDir::new().unwrap();
903 write(
904 &tmp,
905 "config.toml",
906 r#"
907[[mount.entry]]
908src = "home"
909dst = "/home/u"
910
911[[hook]]
912name = "deno-build"
913script = ".yui/bin/build.ts"
914command = "deno"
915args = ["run", "-A", "{{ script_path }}"]
916when_run = "onchange"
917"#,
918 );
919 let r = root(&tmp);
920 let cfg = load(&r, &yui_vars(&r)).unwrap();
921 assert_eq!(cfg.hook.len(), 1);
922 assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
926 }
927}