Skip to main content

yui/
cmd.rs

1//! Command implementations.
2//!
3//! Each `Command` variant in `cli.rs` calls one of these. Currently
4//! implemented: `apply`, `init`, `doctor`. The rest are `todo!()`.
5
6use 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    // 1. Render templates first so the link walk picks up rendered files.
46    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    // 2. Resolve mounts and link.
56    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
105/// Bundle of immutable settings threaded through the apply walk.
106struct 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    // --check is a stricter dry-run: never writes, exits non-zero on drift.
119    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    // For now `link` and `apply` do the same thing (no render/absorb yet).
129    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            // Probe: try loading config
165            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
192// ---------------------------------------------------------------------------
193// internals
194// ---------------------------------------------------------------------------
195
196fn 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            // Templates handled by render flow (not implemented yet).
229            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, /*is_dir=*/ 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, /*is_dir=*/ 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    /// Convert a path to a TOML-string-safe form (forward slashes).
372    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        // Marker file itself shouldn't be visible as a separate link in target;
433        // however with junction/symlink the whole dir shows up so the marker
434        // file IS visible inside. That's fine — the marker is informational.
435    }
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        // Raw file: linked.
488        assert!(target.join(".bashrc").exists());
489        // Template's rendered output: written to source then linked.
490        assert!(source.join("home/.gitconfig").exists());
491        assert!(target.join(".gitconfig").exists());
492        // The .tera file itself is never linked into target.
493        assert!(!target.join(".gitconfig.tera").exists());
494        // Rendered file content carries the yui.os substitution.
495        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        // Existing rendered file untouched.
522        assert_eq!(
523            std::fs::read_to_string(source.join("home/foo")).unwrap(),
524            "manually edited"
525        );
526        // Linking aborted — target empty.
527        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        // Pre-existing target file with different content.
558        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        // Target now has new content (linked from source).
573        assert_eq!(
574            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
575            "new content"
576        );
577
578        // A backup of the old content should exist somewhere under .yui/backup.
579        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}