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
47#[derive(Debug, Clone, Deserialize)]
55pub struct HookConfig {
56 pub name: String,
59 pub script: Utf8PathBuf,
62
63 #[serde(default = "default_hook_command")]
65 pub command: String,
66 #[serde(default = "default_hook_args")]
69 pub args: Vec<String>,
70
71 #[serde(default)]
73 pub when_run: WhenRun,
74 #[serde(default)]
76 pub phase: HookPhase,
77
78 #[serde(default)]
80 pub when: Option<String>,
81}
82
83fn default_hook_command() -> String {
84 "bash".to_string()
85}
86
87fn default_hook_args() -> Vec<String> {
88 vec!["{{ script_path }}".to_string()]
89}
90
91#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "lowercase")]
93pub enum WhenRun {
94 Once,
97 #[default]
101 Onchange,
102 Every,
104}
105
106#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
107#[serde(rename_all = "lowercase")]
108pub enum HookPhase {
109 Pre,
111 #[default]
114 Post,
115}
116
117#[derive(Debug, Deserialize, Default)]
118pub struct UiConfig {
119 #[serde(default)]
120 pub icons: IconsMode,
121}
122
123#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
124#[serde(rename_all = "lowercase")]
125pub enum IconsMode {
126 #[default]
128 Unicode,
129 Nerd,
131 Ascii,
133}
134
135#[derive(Debug, Deserialize, Default)]
136pub struct LinkConfig {
137 #[serde(default)]
138 pub file_mode: FileLinkMode,
139 #[serde(default)]
140 pub dir_mode: DirLinkMode,
141}
142
143#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum FileLinkMode {
146 #[default]
147 Auto,
148 Symlink,
149 Hardlink,
150}
151
152#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum DirLinkMode {
155 #[default]
156 Auto,
157 Symlink,
158 Junction,
159}
160
161#[derive(Debug, Deserialize)]
162pub struct MountConfig {
163 #[serde(default)]
164 pub default_strategy: MountStrategy,
165 #[serde(default = "default_marker_filename")]
166 pub marker_filename: String,
167 #[serde(default)]
168 pub entry: Vec<MountEntry>,
169}
170
171impl Default for MountConfig {
172 fn default() -> Self {
173 Self {
174 default_strategy: MountStrategy::default(),
175 marker_filename: default_marker_filename(),
176 entry: Vec::new(),
177 }
178 }
179}
180
181fn default_marker_filename() -> String {
182 ".yuilink".to_string()
183}
184
185#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
186#[serde(rename_all = "kebab-case")]
187pub enum MountStrategy {
188 #[default]
189 Marker,
190 PerFile,
191}
192
193#[derive(Debug, Deserialize)]
194pub struct MountEntry {
195 pub src: Utf8PathBuf,
196 pub dst: String,
197 #[serde(default)]
198 pub when: Option<String>,
199 #[serde(default)]
200 pub strategy: Option<MountStrategy>,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct AbsorbConfig {
205 #[serde(default = "default_true")]
206 pub auto: bool,
207 #[serde(default = "default_true")]
208 pub require_clean_git: bool,
209 #[serde(default)]
210 pub on_anomaly: AnomalyAction,
211}
212
213impl Default for AbsorbConfig {
214 fn default() -> Self {
215 Self {
216 auto: true,
217 require_clean_git: true,
218 on_anomaly: AnomalyAction::default(),
219 }
220 }
221}
222
223#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
224#[serde(rename_all = "lowercase")]
225pub enum AnomalyAction {
226 #[default]
227 Ask,
228 Skip,
229 Force,
230}
231
232#[derive(Debug, Deserialize)]
233pub struct RenderConfig {
234 #[serde(default = "default_true")]
235 pub manage_gitignore: bool,
236 #[serde(default)]
237 pub rule: Vec<RenderRule>,
238}
239
240impl Default for RenderConfig {
241 fn default() -> Self {
242 Self {
243 manage_gitignore: true,
244 rule: Vec::new(),
245 }
246 }
247}
248
249#[derive(Debug, Deserialize)]
250pub struct RenderRule {
251 pub r#match: String,
252 #[serde(default)]
253 pub when: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257pub struct BackupConfig {
258 #[serde(default = "default_backup_dir")]
259 pub dir: String,
260 #[serde(default = "default_ts_format")]
261 pub timestamp_format: String,
262}
263
264impl Default for BackupConfig {
265 fn default() -> Self {
266 Self {
267 dir: default_backup_dir(),
268 timestamp_format: default_ts_format(),
269 }
270 }
271}
272
273fn default_backup_dir() -> String {
274 ".yui/backup".to_string()
275}
276
277fn default_ts_format() -> String {
278 "%Y%m%d_%H%M%S%3f".to_string()
279}
280
281fn default_true() -> bool {
282 true
283}
284
285pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
287 let files = list_config_files(source)?;
288 if files.is_empty() {
289 return Err(Error::Config(format!(
290 "no config.toml / config.*.toml found at {source}"
291 )));
292 }
293
294 let mut engine = template::Engine::new();
295 let mut merged = toml::Table::new();
296 let mut vars_acc = toml::Table::new();
297
298 for file in &files {
299 let raw = std::fs::read_to_string(file)
300 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
301
302 if let Some(file_vars) = pre_extract_vars(&raw, file)? {
308 deep_merge_table(&mut vars_acc, file_vars);
309 }
310 resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
316
317 let ctx = template::config_render_context(yui, &vars_acc);
321 let rendered = engine.render(&raw, &ctx)?;
322 let parsed: toml::Table =
323 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
324
325 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
330 deep_merge_table(&mut vars_acc, file_vars.clone());
331 }
332 deep_merge_table(&mut merged, parsed);
333 }
334
335 let cfg: Config = toml::Value::Table(merged)
336 .try_into()
337 .map_err(|e| Error::Config(format!("schema: {e}")))?;
338 Ok(cfg)
339}
340
341fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
351 let mut in_vars = false;
352 let mut found_vars = false;
353 let mut lines: Vec<&str> = Vec::new();
354 for line in raw.lines() {
355 let trimmed = line.trim();
356 let header = trimmed.split('#').next().unwrap_or("").trim();
359 if header.starts_with("[") {
360 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
363 if normalized == "[vars]"
364 || normalized.starts_with("[vars.")
365 || normalized.starts_with("[vars[")
366 {
367 in_vars = true;
368 found_vars = true;
369 lines.push(line);
370 continue;
371 }
372 in_vars = false;
373 continue;
374 }
375 if trimmed.starts_with("{%") {
380 continue;
381 }
382 if in_vars {
383 lines.push(line);
384 }
385 }
386 if !found_vars {
387 return Ok(None);
388 }
389 let extracted = lines.join("\n");
390 let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
391 Error::Config(format!(
392 "pre-extract [vars] from {file}: {e} \
393 (the [vars] block must be parseable on its own — \
394 move computed values into a `set` block above the section)"
395 ))
396 })?;
397 if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
398 Ok(Some(vars.clone()))
399 } else {
400 Ok(None)
401 }
402}
403
404const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
410
411fn resolve_vars_refs(
415 vars: &mut toml::Table,
416 yui: &YuiVars,
417 engine: &mut template::Engine,
418) -> Result<()> {
419 for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
420 let ctx = template::config_render_context(yui, vars);
425 let mut changed = false;
426 render_strings_in_table(vars, engine, &ctx, &mut changed)?;
427 if !changed {
428 return Ok(());
429 }
430 }
431 Ok(())
436}
437
438fn render_strings_in_table(
439 table: &mut toml::Table,
440 engine: &mut template::Engine,
441 ctx: &tera::Context,
442 changed: &mut bool,
443) -> Result<()> {
444 for (_k, value) in table.iter_mut() {
445 render_strings_in_value(value, engine, ctx, changed)?;
446 }
447 Ok(())
448}
449
450fn render_strings_in_value(
451 value: &mut toml::Value,
452 engine: &mut template::Engine,
453 ctx: &tera::Context,
454 changed: &mut bool,
455) -> Result<()> {
456 match value {
457 toml::Value::String(s) => {
458 if !s.contains("{{") && !s.contains("{%") {
459 return Ok(());
460 }
461 let rendered = engine.render(s.as_str(), ctx)?;
462 if rendered != *s {
463 *s = rendered;
464 *changed = true;
465 }
466 }
467 toml::Value::Table(t) => {
468 render_strings_in_table(t, engine, ctx, changed)?;
469 }
470 toml::Value::Array(arr) => {
471 for v in arr.iter_mut() {
472 render_strings_in_value(v, engine, ctx, changed)?;
473 }
474 }
475 _ => {}
476 }
477 Ok(())
478}
479
480fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
485 let entries =
486 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
487 let mut files: Vec<Utf8PathBuf> = Vec::new();
488 for entry in entries {
489 let entry = entry.map_err(Error::Io)?;
490 let name_os = entry.file_name();
491 let Some(name) = name_os.to_str() else {
492 continue;
493 };
494 let is_match = name == "config.toml"
495 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
496 if !is_match {
497 continue;
498 }
499 let path = Utf8PathBuf::from_path_buf(entry.path())
500 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
501 files.push(path);
502 }
503 files.sort_by(|a, b| {
504 let an = a.file_name().unwrap_or("");
505 let bn = b.file_name().unwrap_or("");
506 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
507 });
508 Ok(files)
509}
510
511fn file_rank(name: &str) -> u8 {
512 match name {
513 "config.toml" => 0,
514 "config.local.toml" => 2,
515 _ => 1,
516 }
517}
518
519fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
522 for (k, v) in overlay {
523 match (base.remove(&k), v) {
524 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
525 deep_merge_table(&mut bt, ot);
526 base.insert(k, toml::Value::Table(bt));
527 }
528 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
529 ba.extend(oa);
530 base.insert(k, toml::Value::Array(ba));
531 }
532 (_, v) => {
533 base.insert(k, v);
534 }
535 }
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use tempfile::TempDir;
543
544 fn yui_vars(source: &Utf8Path) -> YuiVars {
545 YuiVars {
546 os: "linux".into(),
547 arch: "x86_64".into(),
548 host: "test".into(),
549 user: "u".into(),
550 source: source.to_string(),
551 }
552 }
553
554 fn write(tmp: &TempDir, name: &str, body: &str) {
555 std::fs::write(tmp.path().join(name), body).unwrap();
556 }
557
558 fn root(tmp: &TempDir) -> Utf8PathBuf {
559 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
560 }
561
562 #[test]
563 fn loads_single_file() {
564 let tmp = TempDir::new().unwrap();
565 write(
566 &tmp,
567 "config.toml",
568 r#"
569[vars]
570git_email = "a@example.com"
571
572[[mount.entry]]
573src = "home"
574dst = "/home/u"
575"#,
576 );
577 let r = root(&tmp);
578 let cfg = load(&r, &yui_vars(&r)).unwrap();
579 assert_eq!(
580 cfg.vars.get("git_email").unwrap().as_str(),
581 Some("a@example.com")
582 );
583 assert_eq!(cfg.mount.entry.len(), 1);
584 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
585 }
586
587 #[test]
588 fn local_overrides_base() {
589 let tmp = TempDir::new().unwrap();
590 write(
591 &tmp,
592 "config.toml",
593 r#"
594[vars]
595git_email = "a@example.com"
596work_mode = false
597"#,
598 );
599 write(
600 &tmp,
601 "config.local.toml",
602 r#"
603[vars]
604git_email = "b@work.com"
605"#,
606 );
607 let r = root(&tmp);
608 let cfg = load(&r, &yui_vars(&r)).unwrap();
609 assert_eq!(
610 cfg.vars.get("git_email").unwrap().as_str(),
611 Some("b@work.com")
612 );
613 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
615 }
616
617 #[test]
618 fn alphabetical_middle_files_apply_after_base_before_local() {
619 let tmp = TempDir::new().unwrap();
620 write(
621 &tmp,
622 "config.toml",
623 r#"[vars]
624val = "base""#,
625 );
626 write(
627 &tmp,
628 "config.aaa.toml",
629 r#"[vars]
630val = "aaa""#,
631 );
632 write(
633 &tmp,
634 "config.zzz.toml",
635 r#"[vars]
636val = "zzz""#,
637 );
638 write(
639 &tmp,
640 "config.local.toml",
641 r#"[vars]
642val = "local""#,
643 );
644 let r = root(&tmp);
645 let cfg = load(&r, &yui_vars(&r)).unwrap();
646 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
647 }
648
649 #[test]
650 fn yui_vars_available_in_render() {
651 let tmp = TempDir::new().unwrap();
652 write(
653 &tmp,
654 "config.toml",
655 r#"
656[[mount.entry]]
657src = "home"
658dst = "/{{ yui.os }}/dst"
659"#,
660 );
661 let r = root(&tmp);
662 let cfg = load(&r, &yui_vars(&r)).unwrap();
663 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
664 }
665
666 #[test]
667 fn mount_entries_append_across_files() {
668 let tmp = TempDir::new().unwrap();
669 write(
670 &tmp,
671 "config.toml",
672 r#"
673[[mount.entry]]
674src = "home"
675dst = "/h"
676"#,
677 );
678 write(
679 &tmp,
680 "config.local.toml",
681 r#"
682[[mount.entry]]
683src = "appdata"
684dst = "/a"
685"#,
686 );
687 let r = root(&tmp);
688 let cfg = load(&r, &yui_vars(&r)).unwrap();
689 assert_eq!(cfg.mount.entry.len(), 2);
690 }
691
692 #[test]
693 fn missing_config_errors() {
694 let tmp = TempDir::new().unwrap();
695 let r = root(&tmp);
696 let err = load(&r, &yui_vars(&r)).unwrap_err();
697 assert!(matches!(err, Error::Config(_)));
698 }
699
700 #[test]
701 fn defaults_apply_when_sections_absent() {
702 let tmp = TempDir::new().unwrap();
703 write(&tmp, "config.toml", "");
704 let r = root(&tmp);
705 let cfg = load(&r, &yui_vars(&r)).unwrap();
706 assert!(cfg.absorb.auto);
707 assert!(cfg.absorb.require_clean_git);
708 assert!(cfg.render.manage_gitignore);
709 assert_eq!(cfg.backup.dir, ".yui/backup");
710 assert_eq!(cfg.mount.marker_filename, ".yuilink");
711 }
712
713 #[test]
718 fn vars_visible_to_same_file_render() {
719 let tmp = TempDir::new().unwrap();
720 write(
721 &tmp,
722 "config.toml",
723 r#"
724[vars]
725home_root = "/custom/home"
726
727[[mount.entry]]
728src = "home"
729dst = "{{ vars.home_root }}"
730"#,
731 );
732 let r = root(&tmp);
733 let cfg = load(&r, &yui_vars(&r)).unwrap();
734 assert_eq!(cfg.mount.entry.len(), 1);
735 assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
736 }
737
738 #[test]
742 fn vars_extract_skips_set_blocks() {
743 let tmp = TempDir::new().unwrap();
744 write(
745 &tmp,
746 "config.toml",
747 r#"
748{% set computed = "abc" %}
749[vars]
750plain = "real"
751
752[[mount.entry]]
753src = "home"
754dst = "{{ vars.plain }}"
755"#,
756 );
757 let r = root(&tmp);
758 let cfg = load(&r, &yui_vars(&r)).unwrap();
759 assert_eq!(cfg.mount.entry[0].dst, "real");
760 }
761
762 #[test]
765 fn vars_cross_reference_resolves_either_order() {
766 let tmp = TempDir::new().unwrap();
767 write(
768 &tmp,
769 "config.toml",
770 r#"
771[vars]
772a = "{{ vars.b }}"
773b = "raw"
774
775[[mount.entry]]
776src = "home"
777dst = "{{ vars.a }}"
778"#,
779 );
780 let r = root(&tmp);
781 let cfg = load(&r, &yui_vars(&r)).unwrap();
782 assert_eq!(cfg.mount.entry[0].dst, "raw");
783 }
784
785 #[test]
791 fn vars_cycle_does_not_loop_forever() {
792 let tmp = TempDir::new().unwrap();
793 write(
794 &tmp,
795 "config.toml",
796 r#"
797[vars]
798a = "{{ vars.b }}"
799b = "{{ vars.a }}"
800
801[[mount.entry]]
802src = "home"
803dst = "/anywhere"
804"#,
805 );
806 let r = root(&tmp);
807 let cfg = load(&r, &yui_vars(&r)).unwrap();
811 assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
812 }
813
814 #[test]
822 fn hook_script_vars_survive_config_load_render_verbatim() {
823 let tmp = TempDir::new().unwrap();
824 write(
825 &tmp,
826 "config.toml",
827 r#"
828[[mount.entry]]
829src = "home"
830dst = "/home/u"
831
832[[hook]]
833name = "deno-build"
834script = ".yui/bin/build.ts"
835command = "deno"
836args = ["run", "-A", "{{ script_path }}"]
837when_run = "onchange"
838"#,
839 );
840 let r = root(&tmp);
841 let cfg = load(&r, &yui_vars(&r)).unwrap();
842 assert_eq!(cfg.hook.len(), 1);
843 assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
847 }
848}