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 let ctx = template::template_context(yui, &vars_acc);
302 let rendered = engine.render(&raw, &ctx)?;
303 let parsed: toml::Table =
304 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
305
306 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
307 deep_merge_table(&mut vars_acc, file_vars.clone());
308 }
309 deep_merge_table(&mut merged, parsed);
310 }
311
312 let cfg: Config = toml::Value::Table(merged)
313 .try_into()
314 .map_err(|e| Error::Config(format!("schema: {e}")))?;
315 Ok(cfg)
316}
317
318fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
323 let entries =
324 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
325 let mut files: Vec<Utf8PathBuf> = Vec::new();
326 for entry in entries {
327 let entry = entry.map_err(Error::Io)?;
328 let name_os = entry.file_name();
329 let Some(name) = name_os.to_str() else {
330 continue;
331 };
332 let is_match = name == "config.toml"
333 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
334 if !is_match {
335 continue;
336 }
337 let path = Utf8PathBuf::from_path_buf(entry.path())
338 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
339 files.push(path);
340 }
341 files.sort_by(|a, b| {
342 let an = a.file_name().unwrap_or("");
343 let bn = b.file_name().unwrap_or("");
344 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
345 });
346 Ok(files)
347}
348
349fn file_rank(name: &str) -> u8 {
350 match name {
351 "config.toml" => 0,
352 "config.local.toml" => 2,
353 _ => 1,
354 }
355}
356
357fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
360 for (k, v) in overlay {
361 match (base.remove(&k), v) {
362 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
363 deep_merge_table(&mut bt, ot);
364 base.insert(k, toml::Value::Table(bt));
365 }
366 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
367 ba.extend(oa);
368 base.insert(k, toml::Value::Array(ba));
369 }
370 (_, v) => {
371 base.insert(k, v);
372 }
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 fn yui_vars(source: &Utf8Path) -> YuiVars {
383 YuiVars {
384 os: "linux".into(),
385 arch: "x86_64".into(),
386 host: "test".into(),
387 user: "u".into(),
388 source: source.to_string(),
389 }
390 }
391
392 fn write(tmp: &TempDir, name: &str, body: &str) {
393 std::fs::write(tmp.path().join(name), body).unwrap();
394 }
395
396 fn root(tmp: &TempDir) -> Utf8PathBuf {
397 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
398 }
399
400 #[test]
401 fn loads_single_file() {
402 let tmp = TempDir::new().unwrap();
403 write(
404 &tmp,
405 "config.toml",
406 r#"
407[vars]
408git_email = "a@example.com"
409
410[[mount.entry]]
411src = "home"
412dst = "/home/u"
413"#,
414 );
415 let r = root(&tmp);
416 let cfg = load(&r, &yui_vars(&r)).unwrap();
417 assert_eq!(
418 cfg.vars.get("git_email").unwrap().as_str(),
419 Some("a@example.com")
420 );
421 assert_eq!(cfg.mount.entry.len(), 1);
422 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
423 }
424
425 #[test]
426 fn local_overrides_base() {
427 let tmp = TempDir::new().unwrap();
428 write(
429 &tmp,
430 "config.toml",
431 r#"
432[vars]
433git_email = "a@example.com"
434work_mode = false
435"#,
436 );
437 write(
438 &tmp,
439 "config.local.toml",
440 r#"
441[vars]
442git_email = "b@work.com"
443"#,
444 );
445 let r = root(&tmp);
446 let cfg = load(&r, &yui_vars(&r)).unwrap();
447 assert_eq!(
448 cfg.vars.get("git_email").unwrap().as_str(),
449 Some("b@work.com")
450 );
451 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
453 }
454
455 #[test]
456 fn alphabetical_middle_files_apply_after_base_before_local() {
457 let tmp = TempDir::new().unwrap();
458 write(
459 &tmp,
460 "config.toml",
461 r#"[vars]
462val = "base""#,
463 );
464 write(
465 &tmp,
466 "config.aaa.toml",
467 r#"[vars]
468val = "aaa""#,
469 );
470 write(
471 &tmp,
472 "config.zzz.toml",
473 r#"[vars]
474val = "zzz""#,
475 );
476 write(
477 &tmp,
478 "config.local.toml",
479 r#"[vars]
480val = "local""#,
481 );
482 let r = root(&tmp);
483 let cfg = load(&r, &yui_vars(&r)).unwrap();
484 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
485 }
486
487 #[test]
488 fn yui_vars_available_in_render() {
489 let tmp = TempDir::new().unwrap();
490 write(
491 &tmp,
492 "config.toml",
493 r#"
494[[mount.entry]]
495src = "home"
496dst = "/{{ yui.os }}/dst"
497"#,
498 );
499 let r = root(&tmp);
500 let cfg = load(&r, &yui_vars(&r)).unwrap();
501 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
502 }
503
504 #[test]
505 fn mount_entries_append_across_files() {
506 let tmp = TempDir::new().unwrap();
507 write(
508 &tmp,
509 "config.toml",
510 r#"
511[[mount.entry]]
512src = "home"
513dst = "/h"
514"#,
515 );
516 write(
517 &tmp,
518 "config.local.toml",
519 r#"
520[[mount.entry]]
521src = "appdata"
522dst = "/a"
523"#,
524 );
525 let r = root(&tmp);
526 let cfg = load(&r, &yui_vars(&r)).unwrap();
527 assert_eq!(cfg.mount.entry.len(), 2);
528 }
529
530 #[test]
531 fn missing_config_errors() {
532 let tmp = TempDir::new().unwrap();
533 let r = root(&tmp);
534 let err = load(&r, &yui_vars(&r)).unwrap_err();
535 assert!(matches!(err, Error::Config(_)));
536 }
537
538 #[test]
539 fn defaults_apply_when_sections_absent() {
540 let tmp = TempDir::new().unwrap();
541 write(&tmp, "config.toml", "");
542 let r = root(&tmp);
543 let cfg = load(&r, &yui_vars(&r)).unwrap();
544 assert!(cfg.absorb.auto);
545 assert!(cfg.absorb.require_clean_git);
546 assert!(cfg.render.manage_gitignore);
547 assert_eq!(cfg.backup.dir, ".yui/backup");
548 assert_eq!(cfg.mount.marker_filename, ".yuilink");
549 }
550}