1use anyhow::{Context as _, Result};
7use camino::{Utf8Path, Utf8PathBuf};
8use tracing::{info, warn};
9
10use crate::config::{self, Config, MountStrategy};
11use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
12use crate::marker;
13use crate::mount::{self, ResolvedMount};
14use crate::template;
15use crate::vars::YuiVars;
16use crate::{backup, paths};
17
18pub fn init(source: Option<Utf8PathBuf>, _git_hooks: bool) -> Result<()> {
19 let dir = match source {
20 Some(s) => absolutize(&s)?,
21 None => current_dir_utf8()?,
22 };
23 std::fs::create_dir_all(&dir)?;
24 let config_path = dir.join("config.toml");
25 if config_path.exists() {
26 anyhow::bail!("config.toml already exists at {config_path}");
27 }
28 std::fs::write(&config_path, SKELETON_CONFIG)?;
29 let gitignore_path = dir.join(".gitignore");
30 if !gitignore_path.exists() {
31 std::fs::write(&gitignore_path, SKELETON_GITIGNORE)?;
32 }
33 info!("initialized yui source repo at {dir}");
34 info!("created: {config_path}");
35 info!("next: edit config.toml, then run `yui apply`");
36 Ok(())
37}
38
39pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
40 let source = resolve_source(source)?;
41 let yui = YuiVars::detect(&source);
42 let config = config::load(&source, &yui)?;
43
44 let mut engine = template::Engine::new();
45 let ctx = template::config_context(&yui);
46 let mounts = mount::resolve(
47 &config.mount.entry,
48 config.mount.default_strategy,
49 &mut engine,
50 &ctx,
51 )?;
52
53 let backup_root = source.join(&config.backup.dir);
54 let ctx = ApplyCtx {
55 config: &config,
56 file_mode: resolve_file_mode(config.link.file_mode),
57 dir_mode: resolve_dir_mode(config.link.dir_mode),
58 backup_root: &backup_root,
59 dry_run,
60 };
61
62 info!("source: {source}");
63 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
64 if dry_run {
65 info!("dry-run: nothing will be written");
66 }
67
68 for m in &mounts {
69 info!("mount: {} → {}", m.src, m.dst);
70 process_mount(&source, m, &ctx)?;
71 }
72 Ok(())
73}
74
75struct ApplyCtx<'a> {
77 config: &'a Config,
78 file_mode: EffectiveFileMode,
79 dir_mode: EffectiveDirMode,
80 backup_root: &'a Utf8Path,
81 dry_run: bool,
82}
83
84pub fn render(_source: Option<Utf8PathBuf>, _check: bool, _dry_run: bool) -> Result<()> {
85 todo!("yui render — Tera rendering of *.tera files (next iteration)")
86}
87
88pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
89 apply(source, dry_run)
91}
92
93pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
94 let _source = resolve_source(source)?;
95 if paths_arg.is_empty() {
96 anyhow::bail!("yui unlink: provide at least one target path");
97 }
98 for p in paths_arg {
99 let abs = absolutize(&p)?;
100 info!("unlink: {abs}");
101 link::unlink(&abs)?;
102 }
103 Ok(())
104}
105
106pub fn status(_source: Option<Utf8PathBuf>) -> Result<()> {
107 todo!("yui status — drift detection (needs absorb classifier)")
108}
109
110pub fn absorb(_source: Option<Utf8PathBuf>, _target: Utf8PathBuf, _dry_run: bool) -> Result<()> {
111 todo!("yui absorb — manual absorb (needs absorb classifier)")
112}
113
114pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
115 let yui = YuiVars::detect(Utf8Path::new("."));
116 println!("yui doctor");
117 println!("==========");
118 println!("os: {}", yui.os);
119 println!("arch: {}", yui.arch);
120 println!("user: {}", yui.user);
121 println!("host: {}", yui.host);
122 match resolve_source(source) {
123 Ok(s) => {
124 println!("source: {s}");
125 match config::load(&s, &yui) {
127 Ok(cfg) => println!(
128 "config: ok ({} mount entries, {} render rules)",
129 cfg.mount.entry.len(),
130 cfg.render.rule.len()
131 ),
132 Err(e) => println!("config: ERROR — {e}"),
133 }
134 }
135 Err(e) => println!("source: NOT FOUND — {e}"),
136 }
137 println!();
138 println!("link mode (auto resolves to):");
139 if cfg!(windows) {
140 println!(" files: hardlink");
141 println!(" dirs: junction");
142 } else {
143 println!(" files: symlink");
144 println!(" dirs: symlink");
145 }
146 Ok(())
147}
148
149pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
150 todo!("yui gc-backup — clean up old backups")
151}
152
153fn process_mount(source: &Utf8Path, m: &ResolvedMount, ctx: &ApplyCtx<'_>) -> Result<()> {
158 let src_root = source.join(&m.src);
159 if !src_root.is_dir() {
160 warn!("mount src missing: {src_root}");
161 return Ok(());
162 }
163 walk_and_link(&src_root, &m.dst, ctx, m.strategy)
164}
165
166fn walk_and_link(
167 src_dir: &Utf8Path,
168 dst_dir: &Utf8Path,
169 ctx: &ApplyCtx<'_>,
170 strategy: MountStrategy,
171) -> Result<()> {
172 let marker_filename = &ctx.config.mount.marker_filename;
173
174 if strategy == MountStrategy::Marker && marker::is_marker_dir(src_dir, marker_filename) {
175 link_dir_with_backup(src_dir, dst_dir, ctx)?;
176 return Ok(());
177 }
178
179 for entry in std::fs::read_dir(src_dir)? {
180 let entry = entry?;
181 let name_os = entry.file_name();
182 let Some(name) = name_os.to_str() else {
183 continue;
184 };
185 if name == marker_filename {
186 continue;
187 }
188 if name.ends_with(".tera") {
189 continue;
191 }
192
193 let src_path = src_dir.join(name);
194 let dst_path = dst_dir.join(name);
195 let ft = entry.file_type()?;
196
197 if ft.is_dir() {
198 walk_and_link(&src_path, &dst_path, ctx, strategy)?;
199 } else if ft.is_file() {
200 link_file_with_backup(&src_path, &dst_path, ctx)?;
201 }
202 }
203 Ok(())
204}
205
206fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
207 if ctx.dry_run {
208 info!("[dry-run] link file: {src} → {dst}");
209 return Ok(());
210 }
211 if std::fs::symlink_metadata(dst).is_ok() {
212 backup_existing(dst, ctx.backup_root, false)?;
213 link::unlink(dst)?;
214 }
215 info!("link file: {src} → {dst}");
216 link::link_file(src, dst, ctx.file_mode)?;
217 Ok(())
218}
219
220fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
221 if ctx.dry_run {
222 info!("[dry-run] link dir: {src} → {dst}");
223 return Ok(());
224 }
225 if std::fs::symlink_metadata(dst).is_ok() {
226 backup_existing(dst, ctx.backup_root, true)?;
227 link::unlink(dst)?;
228 }
229 info!("link dir: {src} → {dst}");
230 link::link_dir(src, dst, ctx.dir_mode)?;
231 Ok(())
232}
233
234fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
235 let abs_target = absolutize(target)?;
236 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
237 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
238 info!("backup → {bp}");
239 if is_dir {
240 backup::backup_dir(target, &bp)?;
241 } else {
242 backup::backup_file(target, &bp)?;
243 }
244 Ok(())
245}
246
247fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
248 if let Some(s) = source {
249 return absolutize(&s);
250 }
251 if let Ok(s) = std::env::var("YUI_SOURCE") {
252 return absolutize(Utf8Path::new(&s));
253 }
254 let cwd = current_dir_utf8()?;
255 for ancestor in cwd.ancestors() {
256 if ancestor.join("config.toml").is_file() {
257 return Ok(ancestor.to_path_buf());
258 }
259 }
260 if let Some(home) = home_dir() {
261 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
262 let p = home.join(c);
263 if p.join("config.toml").is_file() {
264 return Ok(p);
265 }
266 }
267 }
268 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
269}
270
271fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
272 if p.is_absolute() {
273 return Ok(p.to_path_buf());
274 }
275 let cwd = current_dir_utf8()?;
276 Ok(cwd.join(p))
277}
278
279fn current_dir_utf8() -> Result<Utf8PathBuf> {
280 let cwd = std::env::current_dir().context("getting cwd")?;
281 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
282}
283
284fn home_dir() -> Option<Utf8PathBuf> {
285 std::env::var("HOME")
286 .ok()
287 .or_else(|| std::env::var("USERPROFILE").ok())
288 .map(Utf8PathBuf::from)
289}
290
291const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
292
293[vars]
294# user-defined values; templates can reference these as {{ vars.foo }}
295
296# [link]
297# file_mode = "auto" # auto | symlink | hardlink
298# dir_mode = "auto" # auto | symlink | junction
299
300[mount]
301default_strategy = "marker"
302
303[[mount.entry]]
304src = "home"
305dst = "{{ env(name='HOME') | default(value=env(name='USERPROFILE')) }}"
306
307# [[mount.entry]]
308# src = "appdata"
309# dst = "{{ env(name='APPDATA') }}"
310# when = "{{ yui.os == 'windows' }}"
311"#;
312
313const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
314/.yui/
315
316# >>> yui rendered (auto-managed, do not edit) >>>
317# <<< yui rendered (auto-managed) <<<
318
319# config.local.toml is per-machine; commit a config.local.example.toml instead.
320config.local.toml
321"#;
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use tempfile::TempDir;
327
328 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
329 Utf8PathBuf::from_path_buf(p).unwrap()
330 }
331
332 fn toml_path(p: &Utf8Path) -> String {
334 p.as_str().replace('\\', "/")
335 }
336
337 #[test]
338 fn apply_links_a_raw_file() {
339 let tmp = TempDir::new().unwrap();
340 let source = utf8(tmp.path().join("dotfiles"));
341 let target = utf8(tmp.path().join("target"));
342 std::fs::create_dir_all(source.join("home")).unwrap();
343 std::fs::create_dir_all(&target).unwrap();
344 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
345
346 let cfg = format!(
347 r#"
348[[mount.entry]]
349src = "home"
350dst = "{}"
351"#,
352 toml_path(&target)
353 );
354 std::fs::write(source.join("config.toml"), cfg).unwrap();
355
356 apply(Some(source), false).unwrap();
357
358 let linked = target.join(".bashrc");
359 assert!(linked.exists(), "expected {linked} to exist");
360 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
361 }
362
363 #[test]
364 fn apply_with_marker_links_whole_directory() {
365 let tmp = TempDir::new().unwrap();
366 let source = utf8(tmp.path().join("dotfiles"));
367 let target = utf8(tmp.path().join("target"));
368 let nvim_src = source.join("home/nvim");
369 std::fs::create_dir_all(&nvim_src).unwrap();
370 std::fs::create_dir_all(&target).unwrap();
371 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
372 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
373 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
374
375 let cfg = format!(
376 r#"
377[[mount.entry]]
378src = "home"
379dst = "{}"
380"#,
381 toml_path(&target)
382 );
383 std::fs::write(source.join("config.toml"), cfg).unwrap();
384
385 apply(Some(source.clone()), false).unwrap();
386
387 let nvim_dst = target.join("nvim");
388 assert!(nvim_dst.exists());
389 assert_eq!(
390 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
391 "-- hi\n"
392 );
393 }
397
398 #[test]
399 fn apply_dry_run_does_not_write() {
400 let tmp = TempDir::new().unwrap();
401 let source = utf8(tmp.path().join("dotfiles"));
402 let target = utf8(tmp.path().join("target"));
403 std::fs::create_dir_all(source.join("home")).unwrap();
404 std::fs::create_dir_all(&target).unwrap();
405 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
406
407 let cfg = format!(
408 r#"
409[[mount.entry]]
410src = "home"
411dst = "{}"
412"#,
413 toml_path(&target)
414 );
415 std::fs::write(source.join("config.toml"), cfg).unwrap();
416
417 apply(Some(source), true).unwrap();
418
419 assert!(!target.join(".bashrc").exists());
420 }
421
422 #[test]
423 fn apply_skips_tera_files() {
424 let tmp = TempDir::new().unwrap();
425 let source = utf8(tmp.path().join("dotfiles"));
426 let target = utf8(tmp.path().join("target"));
427 std::fs::create_dir_all(source.join("home")).unwrap();
428 std::fs::create_dir_all(&target).unwrap();
429 std::fs::write(source.join("home/.gitconfig.tera"), "stuff").unwrap();
430 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
431
432 let cfg = format!(
433 r#"
434[[mount.entry]]
435src = "home"
436dst = "{}"
437"#,
438 toml_path(&target)
439 );
440 std::fs::write(source.join("config.toml"), cfg).unwrap();
441
442 apply(Some(source), false).unwrap();
443
444 assert!(target.join(".bashrc").exists());
445 assert!(!target.join(".gitconfig").exists());
447 assert!(!target.join(".gitconfig.tera").exists());
448 }
449
450 #[test]
451 fn init_creates_skeleton_when_dir_empty() {
452 let tmp = TempDir::new().unwrap();
453 let dir = utf8(tmp.path().join("new_dotfiles"));
454 init(Some(dir.clone()), false).unwrap();
455 assert!(dir.join("config.toml").is_file());
456 assert!(dir.join(".gitignore").is_file());
457 }
458
459 #[test]
460 fn init_refuses_to_overwrite_existing_config() {
461 let tmp = TempDir::new().unwrap();
462 let dir = utf8(tmp.path().join("dotfiles"));
463 std::fs::create_dir_all(&dir).unwrap();
464 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
465 let err = init(Some(dir), false).unwrap_err();
466 assert!(format!("{err}").contains("already exists"));
467 }
468
469 #[test]
470 fn apply_with_existing_target_backs_up() {
471 let tmp = TempDir::new().unwrap();
472 let source = utf8(tmp.path().join("dotfiles"));
473 let target = utf8(tmp.path().join("target"));
474 std::fs::create_dir_all(source.join("home")).unwrap();
475 std::fs::create_dir_all(&target).unwrap();
476 std::fs::write(source.join("home/.bashrc"), "new content").unwrap();
477 std::fs::write(target.join(".bashrc"), "old content").unwrap();
479
480 let cfg = format!(
481 r#"
482[[mount.entry]]
483src = "home"
484dst = "{}"
485"#,
486 toml_path(&target)
487 );
488 std::fs::write(source.join("config.toml"), cfg).unwrap();
489
490 apply(Some(source.clone()), false).unwrap();
491
492 assert_eq!(
494 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
495 "new content"
496 );
497
498 let backup_root = source.join(".yui/backup");
500 assert!(backup_root.exists(), "backup root should exist");
501 let mut found_old = false;
502 for entry in walkdir(&backup_root) {
503 if let Ok(s) = std::fs::read_to_string(&entry) {
504 if s == "old content" {
505 found_old = true;
506 break;
507 }
508 }
509 }
510 assert!(found_old, "expected backup containing 'old content'");
511 }
512
513 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
514 let mut out = Vec::new();
515 let mut stack = vec![root.to_path_buf()];
516 while let Some(dir) = stack.pop() {
517 let Ok(entries) = std::fs::read_dir(&dir) else {
518 continue;
519 };
520 for e in entries.flatten() {
521 let p = utf8(e.path());
522 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
523 stack.push(p);
524 } else {
525 out.push(p);
526 }
527 }
528 }
529 out
530 }
531}