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