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::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
75/// Bundle of immutable settings threaded through the apply walk.
76struct 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    // For now `link` and `apply` do the same thing (no render/absorb yet).
90    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            // Probe: try loading config
126            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
153// ---------------------------------------------------------------------------
154// internals
155// ---------------------------------------------------------------------------
156
157fn 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            // Templates handled by render flow (not implemented yet).
190            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, /*is_dir=*/ 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, /*is_dir=*/ 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    /// Convert a path to a TOML-string-safe form (forward slashes).
333    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        // Marker file itself shouldn't be visible as a separate link in target;
394        // however with junction/symlink the whole dir shows up so the marker
395        // file IS visible inside. That's fine — the marker is informational.
396    }
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        // Templates are NOT linked yet (pending render flow).
446        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        // Pre-existing target file with different content.
478        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        // Target now has new content (linked from source).
493        assert_eq!(
494            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
495            "new content"
496        );
497
498        // A backup of the old content should exist somewhere under .yui/backup.
499        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}