Skip to main content

lean_ctx/
dropin.rs

1//! Drop-in style installation.
2//!
3//! Some users (or their dotfiles managers — chezmoi, yadm, stow, oh-my-zsh
4//! `custom/`, etc.) keep their shell config split into a small main file plus
5//! a directory of numbered fragments that the main file sources in lexical
6//! order (e.g. `~/.zshenv.d/00-homebrew.zsh`, `10-fnm.zsh`, ...).
7//!
8//! When that convention is in use, appending an inline fenced block to the
9//! main rc file creates drift between the main file and the dotfiles source
10//! of truth. The drop-in install mode writes the same hook content into a
11//! single fragment file in the `.d/` directory instead, leaving the main rc
12//! file untouched.
13//!
14//! Detection is conservative: we require both that the `.d/` directory
15//! exists AND that the main rc file references it from a non-comment line.
16//! That avoids treating an unused empty directory as opt-in.
17
18use std::path::{Path, PathBuf};
19
20/// Detect whether the user's rc file in `home` references the named drop-in
21/// directory and that directory exists. Returns the resolved directory path
22/// on success.
23///
24/// Arguments are kept generic so this works for `.zshenv` / `.zshenv.d`,
25/// `.zshrc` / `.zshrc.d`, `.bashrc` / `.bashrc.d`, etc.
26pub fn detect(home: &Path, rc_file_name: &str, dropin_dir_name: &str) -> Option<PathBuf> {
27    let dir = home.join(dropin_dir_name);
28    if !dir.is_dir() {
29        return None;
30    }
31    let rc_contents = std::fs::read_to_string(home.join(rc_file_name)).ok()?;
32    if rc_references_dropin(&rc_contents, dropin_dir_name) {
33        Some(dir)
34    } else {
35        None
36    }
37}
38
39/// True if any non-comment line in `rc_contents` mentions `dropin_dir_name`.
40///
41/// We deliberately don't try to parse the shell — any user who has put the
42/// directory name in their live config (a source loop, a glob `for` loop,
43/// even an `autoload` call) has made the intent clear enough.
44pub fn rc_references_dropin(rc_contents: &str, dropin_dir_name: &str) -> bool {
45    rc_contents.lines().any(|line| {
46        let trimmed = line.trim_start();
47        if trimmed.starts_with('#') {
48            return false;
49        }
50        line.contains(dropin_dir_name)
51    })
52}
53
54/// Write `content` to `<dir>/<filename>`, creating `dir` if needed.
55///
56/// Idempotent: overwrites any existing file. The trailing newline is
57/// normalised so re-runs with identical content produce identical bytes.
58pub fn write(dir: &Path, filename: &str, content: &str, quiet: bool, label: &str) {
59    if let Err(e) = std::fs::create_dir_all(dir) {
60        tracing::error!("Cannot create {}: {e}", dir.display());
61        return;
62    }
63    let file = dir.join(filename);
64    let body = format!("{}\n", content.trim_end_matches('\n'));
65    if let Err(e) = std::fs::write(&file, body) {
66        tracing::error!("Cannot write {}: {e}", file.display());
67        return;
68    }
69    if !quiet {
70        eprintln!("  Installed {label} at {}", file.display());
71    }
72}
73
74/// Remove `<dir>/<filename>` if it exists. No-op otherwise.
75pub fn remove(dir: &Path, filename: &str, quiet: bool, label: &str) {
76    let file = dir.join(filename);
77    if !file.exists() {
78        return;
79    }
80    if let Err(e) = std::fs::remove_file(&file) {
81        tracing::error!("Cannot remove {}: {e}", file.display());
82        return;
83    }
84    if !quiet {
85        println!("  Removed {label} from {}", file.display());
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn fixture() -> tempfile::TempDir {
94        tempfile::tempdir().expect("tempdir")
95    }
96
97    #[test]
98    fn rc_references_dropin_matches_source_loop() {
99        let rc = r#"# top
100if [[ -d "$HOME/.zshenv.d" ]]; then
101    for f in "$HOME/.zshenv.d"/*.zsh(N); do source "$f"; done
102fi
103"#;
104        assert!(rc_references_dropin(rc, ".zshenv.d"));
105    }
106
107    #[test]
108    fn rc_references_dropin_ignores_comment_only_mentions() {
109        let rc = "# Once we have a ~/.zshenv.d we should adopt it.\nexport PATH=/usr/bin\n";
110        assert!(!rc_references_dropin(rc, ".zshenv.d"));
111    }
112
113    #[test]
114    fn rc_references_dropin_handles_empty_file() {
115        assert!(!rc_references_dropin("", ".zshenv.d"));
116    }
117
118    #[test]
119    fn detect_returns_none_without_directory() {
120        let tmp = fixture();
121        std::fs::write(
122            tmp.path().join(".zshenv"),
123            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
124        )
125        .unwrap();
126        assert!(detect(tmp.path(), ".zshenv", ".zshenv.d").is_none());
127    }
128
129    #[test]
130    fn detect_returns_none_with_directory_but_no_reference() {
131        let tmp = fixture();
132        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
133        std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
134        assert!(detect(tmp.path(), ".zshenv", ".zshenv.d").is_none());
135    }
136
137    #[test]
138    fn detect_returns_dir_when_loop_and_directory_present() {
139        let tmp = fixture();
140        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
141        std::fs::write(
142            tmp.path().join(".zshenv"),
143            "if [[ -d \"$HOME/.zshenv.d\" ]]; then\n  for f in $HOME/.zshenv.d/*.zsh; do source $f; done\nfi\n",
144        )
145        .unwrap();
146        let got = detect(tmp.path(), ".zshenv", ".zshenv.d");
147        assert_eq!(got, Some(tmp.path().join(".zshenv.d")));
148    }
149
150    #[test]
151    fn detect_returns_none_when_rc_file_missing() {
152        let tmp = fixture();
153        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
154        assert!(detect(tmp.path(), ".zshenv", ".zshenv.d").is_none());
155    }
156
157    #[test]
158    fn write_then_remove_roundtrip() {
159        let tmp = fixture();
160        let dir = tmp.path().join(".zshenv.d");
161        write(&dir, "00-lean-ctx.zsh", "echo hi", true, "test");
162        let file = dir.join("00-lean-ctx.zsh");
163        assert!(file.exists());
164        let body = std::fs::read_to_string(&file).unwrap();
165        assert_eq!(body, "echo hi\n");
166        remove(&dir, "00-lean-ctx.zsh", true, "test");
167        assert!(!file.exists());
168    }
169
170    #[test]
171    fn write_creates_missing_directory() {
172        let tmp = fixture();
173        let dir = tmp.path().join("nested").join(".zshenv.d");
174        write(&dir, "00-lean-ctx.zsh", "echo hi", true, "test");
175        assert!(dir.join("00-lean-ctx.zsh").exists());
176    }
177
178    #[test]
179    fn write_is_idempotent_for_identical_content() {
180        let tmp = fixture();
181        let dir = tmp.path().join(".zshenv.d");
182        write(&dir, "00-lean-ctx.zsh", "echo hi", true, "test");
183        let first = std::fs::read(dir.join("00-lean-ctx.zsh")).unwrap();
184        write(&dir, "00-lean-ctx.zsh", "echo hi", true, "test");
185        let second = std::fs::read(dir.join("00-lean-ctx.zsh")).unwrap();
186        assert_eq!(first, second);
187    }
188
189    #[test]
190    fn write_overwrites_changed_content() {
191        let tmp = fixture();
192        let dir = tmp.path().join(".zshenv.d");
193        write(&dir, "00-lean-ctx.zsh", "echo old", true, "test");
194        write(&dir, "00-lean-ctx.zsh", "echo new", true, "test");
195        let body = std::fs::read_to_string(dir.join("00-lean-ctx.zsh")).unwrap();
196        assert_eq!(body, "echo new\n");
197    }
198
199    #[test]
200    fn remove_is_noop_when_file_missing() {
201        let tmp = fixture();
202        let dir = tmp.path().join(".zshenv.d");
203        std::fs::create_dir_all(&dir).unwrap();
204        remove(&dir, "00-lean-ctx.zsh", true, "test");
205    }
206
207    #[test]
208    fn remove_is_noop_when_directory_missing() {
209        let tmp = fixture();
210        let dir = tmp.path().join(".zshenv.d");
211        // Directory deliberately not created.
212        remove(&dir, "00-lean-ctx.zsh", true, "test");
213    }
214}