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