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
44#[derive(Debug, Deserialize, Default)]
45pub struct UiConfig {
46 #[serde(default)]
47 pub icons: IconsMode,
48}
49
50#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
51#[serde(rename_all = "lowercase")]
52pub enum IconsMode {
53 #[default]
55 Unicode,
56 Nerd,
58 Ascii,
60}
61
62#[derive(Debug, Deserialize, Default)]
63pub struct LinkConfig {
64 #[serde(default)]
65 pub file_mode: FileLinkMode,
66 #[serde(default)]
67 pub dir_mode: DirLinkMode,
68}
69
70#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
71#[serde(rename_all = "lowercase")]
72pub enum FileLinkMode {
73 #[default]
74 Auto,
75 Symlink,
76 Hardlink,
77}
78
79#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
80#[serde(rename_all = "lowercase")]
81pub enum DirLinkMode {
82 #[default]
83 Auto,
84 Symlink,
85 Junction,
86}
87
88#[derive(Debug, Deserialize)]
89pub struct MountConfig {
90 #[serde(default)]
91 pub default_strategy: MountStrategy,
92 #[serde(default = "default_marker_filename")]
93 pub marker_filename: String,
94 #[serde(default)]
95 pub entry: Vec<MountEntry>,
96}
97
98impl Default for MountConfig {
99 fn default() -> Self {
100 Self {
101 default_strategy: MountStrategy::default(),
102 marker_filename: default_marker_filename(),
103 entry: Vec::new(),
104 }
105 }
106}
107
108fn default_marker_filename() -> String {
109 ".yuilink".to_string()
110}
111
112#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
113#[serde(rename_all = "kebab-case")]
114pub enum MountStrategy {
115 #[default]
116 Marker,
117 PerFile,
118}
119
120#[derive(Debug, Deserialize)]
121pub struct MountEntry {
122 pub src: Utf8PathBuf,
123 pub dst: String,
124 #[serde(default)]
125 pub when: Option<String>,
126 #[serde(default)]
127 pub strategy: Option<MountStrategy>,
128}
129
130#[derive(Debug, Deserialize)]
131pub struct AbsorbConfig {
132 #[serde(default = "default_true")]
133 pub auto: bool,
134 #[serde(default = "default_true")]
135 pub require_clean_git: bool,
136 #[serde(default)]
137 pub on_anomaly: AnomalyAction,
138}
139
140impl Default for AbsorbConfig {
141 fn default() -> Self {
142 Self {
143 auto: true,
144 require_clean_git: true,
145 on_anomaly: AnomalyAction::default(),
146 }
147 }
148}
149
150#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
151#[serde(rename_all = "lowercase")]
152pub enum AnomalyAction {
153 #[default]
154 Ask,
155 Skip,
156 Force,
157}
158
159#[derive(Debug, Deserialize)]
160pub struct RenderConfig {
161 #[serde(default = "default_true")]
162 pub manage_gitignore: bool,
163 #[serde(default)]
164 pub rule: Vec<RenderRule>,
165}
166
167impl Default for RenderConfig {
168 fn default() -> Self {
169 Self {
170 manage_gitignore: true,
171 rule: Vec::new(),
172 }
173 }
174}
175
176#[derive(Debug, Deserialize)]
177pub struct RenderRule {
178 pub r#match: String,
179 #[serde(default)]
180 pub when: Option<String>,
181}
182
183#[derive(Debug, Deserialize)]
184pub struct BackupConfig {
185 #[serde(default = "default_backup_dir")]
186 pub dir: String,
187 #[serde(default = "default_ts_format")]
188 pub timestamp_format: String,
189}
190
191impl Default for BackupConfig {
192 fn default() -> Self {
193 Self {
194 dir: default_backup_dir(),
195 timestamp_format: default_ts_format(),
196 }
197 }
198}
199
200fn default_backup_dir() -> String {
201 ".yui/backup".to_string()
202}
203
204fn default_ts_format() -> String {
205 "%Y%m%d_%H%M%S%3f".to_string()
206}
207
208fn default_true() -> bool {
209 true
210}
211
212pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
214 let files = list_config_files(source)?;
215 if files.is_empty() {
216 return Err(Error::Config(format!(
217 "no config.toml / config.*.toml found at {source}"
218 )));
219 }
220
221 let mut engine = template::Engine::new();
222 let mut merged = toml::Table::new();
223 let mut vars_acc = toml::Table::new();
224
225 for file in &files {
226 let raw = std::fs::read_to_string(file)
227 .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
228 let ctx = template::template_context(yui, &vars_acc);
229 let rendered = engine.render(&raw, &ctx)?;
230 let parsed: toml::Table =
231 toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
232
233 if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
234 deep_merge_table(&mut vars_acc, file_vars.clone());
235 }
236 deep_merge_table(&mut merged, parsed);
237 }
238
239 let cfg: Config = toml::Value::Table(merged)
240 .try_into()
241 .map_err(|e| Error::Config(format!("schema: {e}")))?;
242 Ok(cfg)
243}
244
245fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
250 let entries =
251 std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
252 let mut files: Vec<Utf8PathBuf> = Vec::new();
253 for entry in entries {
254 let entry = entry.map_err(Error::Io)?;
255 let name_os = entry.file_name();
256 let Some(name) = name_os.to_str() else {
257 continue;
258 };
259 let is_match = name == "config.toml"
260 || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
261 if !is_match {
262 continue;
263 }
264 let path = Utf8PathBuf::from_path_buf(entry.path())
265 .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
266 files.push(path);
267 }
268 files.sort_by(|a, b| {
269 let an = a.file_name().unwrap_or("");
270 let bn = b.file_name().unwrap_or("");
271 file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
272 });
273 Ok(files)
274}
275
276fn file_rank(name: &str) -> u8 {
277 match name {
278 "config.toml" => 0,
279 "config.local.toml" => 2,
280 _ => 1,
281 }
282}
283
284fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
287 for (k, v) in overlay {
288 match (base.remove(&k), v) {
289 (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
290 deep_merge_table(&mut bt, ot);
291 base.insert(k, toml::Value::Table(bt));
292 }
293 (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
294 ba.extend(oa);
295 base.insert(k, toml::Value::Array(ba));
296 }
297 (_, v) => {
298 base.insert(k, v);
299 }
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use tempfile::TempDir;
308
309 fn yui_vars(source: &Utf8Path) -> YuiVars {
310 YuiVars {
311 os: "linux".into(),
312 arch: "x86_64".into(),
313 host: "test".into(),
314 user: "u".into(),
315 source: source.to_string(),
316 }
317 }
318
319 fn write(tmp: &TempDir, name: &str, body: &str) {
320 std::fs::write(tmp.path().join(name), body).unwrap();
321 }
322
323 fn root(tmp: &TempDir) -> Utf8PathBuf {
324 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
325 }
326
327 #[test]
328 fn loads_single_file() {
329 let tmp = TempDir::new().unwrap();
330 write(
331 &tmp,
332 "config.toml",
333 r#"
334[vars]
335git_email = "a@example.com"
336
337[[mount.entry]]
338src = "home"
339dst = "/home/u"
340"#,
341 );
342 let r = root(&tmp);
343 let cfg = load(&r, &yui_vars(&r)).unwrap();
344 assert_eq!(
345 cfg.vars.get("git_email").unwrap().as_str(),
346 Some("a@example.com")
347 );
348 assert_eq!(cfg.mount.entry.len(), 1);
349 assert_eq!(cfg.mount.entry[0].dst, "/home/u");
350 }
351
352 #[test]
353 fn local_overrides_base() {
354 let tmp = TempDir::new().unwrap();
355 write(
356 &tmp,
357 "config.toml",
358 r#"
359[vars]
360git_email = "a@example.com"
361work_mode = false
362"#,
363 );
364 write(
365 &tmp,
366 "config.local.toml",
367 r#"
368[vars]
369git_email = "b@work.com"
370"#,
371 );
372 let r = root(&tmp);
373 let cfg = load(&r, &yui_vars(&r)).unwrap();
374 assert_eq!(
375 cfg.vars.get("git_email").unwrap().as_str(),
376 Some("b@work.com")
377 );
378 assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
380 }
381
382 #[test]
383 fn alphabetical_middle_files_apply_after_base_before_local() {
384 let tmp = TempDir::new().unwrap();
385 write(
386 &tmp,
387 "config.toml",
388 r#"[vars]
389val = "base""#,
390 );
391 write(
392 &tmp,
393 "config.aaa.toml",
394 r#"[vars]
395val = "aaa""#,
396 );
397 write(
398 &tmp,
399 "config.zzz.toml",
400 r#"[vars]
401val = "zzz""#,
402 );
403 write(
404 &tmp,
405 "config.local.toml",
406 r#"[vars]
407val = "local""#,
408 );
409 let r = root(&tmp);
410 let cfg = load(&r, &yui_vars(&r)).unwrap();
411 assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
412 }
413
414 #[test]
415 fn yui_vars_available_in_render() {
416 let tmp = TempDir::new().unwrap();
417 write(
418 &tmp,
419 "config.toml",
420 r#"
421[[mount.entry]]
422src = "home"
423dst = "/{{ yui.os }}/dst"
424"#,
425 );
426 let r = root(&tmp);
427 let cfg = load(&r, &yui_vars(&r)).unwrap();
428 assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
429 }
430
431 #[test]
432 fn mount_entries_append_across_files() {
433 let tmp = TempDir::new().unwrap();
434 write(
435 &tmp,
436 "config.toml",
437 r#"
438[[mount.entry]]
439src = "home"
440dst = "/h"
441"#,
442 );
443 write(
444 &tmp,
445 "config.local.toml",
446 r#"
447[[mount.entry]]
448src = "appdata"
449dst = "/a"
450"#,
451 );
452 let r = root(&tmp);
453 let cfg = load(&r, &yui_vars(&r)).unwrap();
454 assert_eq!(cfg.mount.entry.len(), 2);
455 }
456
457 #[test]
458 fn missing_config_errors() {
459 let tmp = TempDir::new().unwrap();
460 let r = root(&tmp);
461 let err = load(&r, &yui_vars(&r)).unwrap_err();
462 assert!(matches!(err, Error::Config(_)));
463 }
464
465 #[test]
466 fn defaults_apply_when_sections_absent() {
467 let tmp = TempDir::new().unwrap();
468 write(&tmp, "config.toml", "");
469 let r = root(&tmp);
470 let cfg = load(&r, &yui_vars(&r)).unwrap();
471 assert!(cfg.absorb.auto);
472 assert!(cfg.absorb.require_clean_git);
473 assert!(cfg.render.manage_gitignore);
474 assert_eq!(cfg.backup.dir, ".yui/backup");
475 assert_eq!(cfg.mount.marker_filename, ".yuilink");
476 }
477}