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