Skip to main content

dodot_lib/shell/
mod.rs

1//! Shell integration — generates `dodot-init.sh`.
2//!
3//! Unlike the Go implementation which ships a ~400-line shell script
4//! that re-discovers the datastore layout at runtime, we generate a
5//! flat, declarative script from the actual datastore state. This
6//! means:
7//!
8//! - Zero logic duplication between Rust and shell
9//! - The script is just `source` and `PATH=` lines — trivially fast
10//! - Changes to the datastore layout only need to happen in Rust
11//!
12//! The generated script is written to `data_dir/shell/dodot-init.sh`.
13//! Users source it from their shell profile:
14//!
15//! ```sh
16//! [ -f ~/.local/share/dodot/shell/dodot-init.sh ] && . ~/.local/share/dodot/shell/dodot-init.sh
17//! ```
18//!
19//! In the future, this can also be exposed as `dodot init-sh` or
20//! a minimal standalone binary for even faster shell startup.
21
22use std::fmt::Write;
23use std::path::PathBuf;
24
25use crate::fs::Fs;
26use crate::paths::Pather;
27use crate::Result;
28
29/// Generate the shell init script content from the current datastore state.
30///
31/// Scans the datastore for:
32/// - `packs/*/shell/*` — symlinks to shell scripts → `source` lines
33/// - `packs/*/path/*` — symlinks to directories → `PATH=` lines
34///
35/// Returns the script content as a string.
36pub fn generate_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<String> {
37    let mut script = String::new();
38
39    writeln!(script, "#!/bin/sh").unwrap();
40    writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
41    writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
42    writeln!(script).unwrap();
43
44    // Discover all packs with state
45    let packs_dir = paths.data_dir().join("packs");
46    if !fs.exists(&packs_dir) {
47        return Ok(script);
48    }
49
50    let pack_entries = fs.read_dir(&packs_dir)?;
51
52    // Collect shell sources and path additions separately so we can
53    // group them in the output for readability.
54    let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
55    let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
56
57    for pack_entry in &pack_entries {
58        if !pack_entry.is_dir {
59            continue;
60        }
61        let pack_name = &pack_entry.name;
62
63        // Shell handler: source scripts
64        let shell_dir = paths.handler_data_dir(pack_name, "shell");
65        if fs.is_dir(&shell_dir) {
66            if let Ok(entries) = fs.read_dir(&shell_dir) {
67                for entry in entries {
68                    if !entry.is_symlink {
69                        continue;
70                    }
71                    // Follow the symlink to get the actual file path
72                    let target = fs.readlink(&entry.path)?;
73                    shell_sources.push((pack_name.clone(), target));
74                }
75            }
76        }
77
78        // Path handler: add to PATH
79        let path_dir = paths.handler_data_dir(pack_name, "path");
80        if fs.is_dir(&path_dir) {
81            if let Ok(entries) = fs.read_dir(&path_dir) {
82                for entry in entries {
83                    if !entry.is_symlink {
84                        continue;
85                    }
86                    let target = fs.readlink(&entry.path)?;
87                    path_additions.push((pack_name.clone(), target));
88                }
89            }
90        }
91    }
92
93    // Emit PATH additions
94    if !path_additions.is_empty() {
95        writeln!(script, "# PATH additions").unwrap();
96        for (pack, target) in &path_additions {
97            writeln!(script, "# [{pack}]").unwrap();
98            writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
99        }
100        writeln!(script).unwrap();
101    }
102
103    // Emit shell sources
104    if !shell_sources.is_empty() {
105        writeln!(script, "# Shell scripts").unwrap();
106        for (pack, target) in &shell_sources {
107            writeln!(script, "# [{pack}]").unwrap();
108            writeln!(
109                script,
110                "[ -f \"{}\" ] && . \"{}\"",
111                target.display(),
112                target.display()
113            )
114            .unwrap();
115        }
116        writeln!(script).unwrap();
117    }
118
119    Ok(script)
120}
121
122/// Generate and write the init script to `data_dir/shell/dodot-init.sh`.
123///
124/// Returns the path where the script was written.
125pub fn write_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<PathBuf> {
126    let script_content = generate_init_script(fs, paths)?;
127    let script_path = paths.init_script_path();
128
129    fs.mkdir_all(paths.shell_dir())?;
130    fs.write_file(&script_path, script_content.as_bytes())?;
131    fs.set_permissions(&script_path, 0o755)?;
132
133    Ok(script_path)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
140    use crate::testing::TempEnvironment;
141    use std::sync::Arc;
142
143    struct NoopRunner;
144    impl CommandRunner for NoopRunner {
145        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
146            Ok(CommandOutput {
147                exit_code: 0,
148                stdout: String::new(),
149                stderr: String::new(),
150            })
151        }
152    }
153
154    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
155        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
156    }
157
158    #[test]
159    fn empty_datastore_produces_minimal_script() {
160        let env = TempEnvironment::builder().build();
161        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
162
163        assert!(script.starts_with("#!/bin/sh"));
164        assert!(script.contains("Generated by dodot"));
165        // No source or PATH lines
166        assert!(!script.contains("export PATH"));
167        assert!(!script.contains(". \""));
168    }
169
170    #[test]
171    fn shell_handler_state_produces_source_lines() {
172        let env = TempEnvironment::builder()
173            .pack("vim")
174            .file("aliases.sh", "alias vi=vim")
175            .done()
176            .build();
177
178        let ds = make_datastore(&env);
179        let source = env.dotfiles_root.join("vim/aliases.sh");
180        ds.create_data_link("vim", "shell", &source).unwrap();
181
182        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
183
184        assert!(script.contains("# Shell scripts"), "script:\n{script}");
185        assert!(script.contains("# [vim]"), "script:\n{script}");
186        assert!(
187            script.contains(&format!(
188                "[ -f \"{}\" ] && . \"{}\"",
189                source.display(),
190                source.display()
191            )),
192            "script:\n{script}"
193        );
194    }
195
196    #[test]
197    fn path_handler_state_produces_path_lines() {
198        let env = TempEnvironment::builder()
199            .pack("vim")
200            .file("bin/myscript", "#!/bin/sh")
201            .done()
202            .build();
203
204        let ds = make_datastore(&env);
205        let source = env.dotfiles_root.join("vim/bin");
206        ds.create_data_link("vim", "path", &source).unwrap();
207
208        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
209
210        assert!(script.contains("# PATH additions"), "script:\n{script}");
211        assert!(script.contains("# [vim]"), "script:\n{script}");
212        assert!(
213            script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
214            "script:\n{script}"
215        );
216    }
217
218    #[test]
219    fn multiple_packs_combined() {
220        let env = TempEnvironment::builder()
221            .pack("git")
222            .file("aliases.sh", "alias gs='git status'")
223            .done()
224            .pack("vim")
225            .file("aliases.sh", "alias vi=vim")
226            .file("bin/vimrun", "#!/bin/sh")
227            .done()
228            .build();
229
230        let ds = make_datastore(&env);
231
232        // Shell scripts
233        ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
234            .unwrap();
235        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
236            .unwrap();
237
238        // Path
239        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
240            .unwrap();
241
242        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
243
244        // Should have both shell sources
245        assert!(script.contains("# [git]"), "script:\n{script}");
246        assert!(script.contains("# [vim]"), "script:\n{script}");
247        // Should have PATH addition
248        assert!(script.contains("export PATH="), "script:\n{script}");
249        // Should have source lines
250        let source_count = script.matches(". \"").count();
251        assert_eq!(
252            source_count, 2,
253            "expected 2 source lines, script:\n{script}"
254        );
255    }
256
257    #[test]
258    fn write_init_script_creates_executable_file() {
259        let env = TempEnvironment::builder()
260            .pack("vim")
261            .file("aliases.sh", "alias vi=vim")
262            .done()
263            .build();
264
265        let ds = make_datastore(&env);
266        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
267            .unwrap();
268
269        let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
270
271        assert_eq!(script_path, env.paths.init_script_path());
272        env.assert_exists(&script_path);
273
274        let content = env.fs.read_to_string(&script_path).unwrap();
275        assert!(content.starts_with("#!/bin/sh"));
276        assert!(content.contains("aliases.sh"));
277
278        // Check executable permission
279        let meta = std::fs::metadata(&script_path).unwrap();
280        use std::os::unix::fs::PermissionsExt;
281        assert_eq!(meta.permissions().mode() & 0o111, 0o111);
282    }
283
284    #[test]
285    fn script_regenerated_reflects_current_state() {
286        let env = TempEnvironment::builder()
287            .pack("vim")
288            .file("aliases.sh", "alias vi=vim")
289            .done()
290            .build();
291
292        let ds = make_datastore(&env);
293
294        // Initially empty
295        let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
296        assert!(!script1.contains("aliases.sh"));
297
298        // Deploy shell script
299        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
300            .unwrap();
301
302        let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
303        assert!(script2.contains("aliases.sh"));
304
305        // Remove state
306        ds.remove_state("vim", "shell").unwrap();
307
308        let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
309        assert!(!script3.contains("aliases.sh"));
310    }
311
312    #[test]
313    fn ignores_non_symlink_files_in_handler_dirs() {
314        let env = TempEnvironment::builder().build();
315
316        // Create a non-symlink file in the shell handler dir
317        let shell_dir = env.paths.handler_data_dir("vim", "shell");
318        env.fs.mkdir_all(&shell_dir).unwrap();
319        env.fs
320            .write_file(&shell_dir.join("not-a-symlink"), b"noise")
321            .unwrap();
322
323        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
324        assert!(!script.contains("not-a-symlink"));
325    }
326
327    #[test]
328    fn path_additions_come_before_shell_sources() {
329        let env = TempEnvironment::builder()
330            .pack("vim")
331            .file("aliases.sh", "alias vi=vim")
332            .file("bin/myscript", "#!/bin/sh")
333            .done()
334            .build();
335
336        let ds = make_datastore(&env);
337        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
338            .unwrap();
339        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
340            .unwrap();
341
342        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
343
344        let path_pos = script.find("# PATH additions").unwrap();
345        let shell_pos = script.find("# Shell scripts").unwrap();
346        assert!(
347            path_pos < shell_pos,
348            "PATH additions should come before shell sources"
349        );
350    }
351}