1use std::fmt::Write;
23use std::path::PathBuf;
24
25use crate::fs::Fs;
26use crate::paths::Pather;
27use crate::Result;
28
29fn 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
39pub 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 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 let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); for pack_entry in &pack_entries {
69 if !pack_entry.is_dir {
70 continue;
71 }
72 let pack_name = &pack_entry.name;
73
74 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 let target = fs.readlink(&entry.path)?;
84 shell_sources.push((pack_name.clone(), target));
85 }
86 }
87 }
88
89 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 path_additions.is_empty() && shell_sources.is_empty() {
106 append_empty_notice(&mut script);
107 return Ok(script);
108 }
109
110 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 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
139pub 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 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 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 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 assert!(script.contains("# [git]"), "script:\n{script}");
266 assert!(script.contains("# [vim]"), "script:\n{script}");
267 assert!(script.contains("export PATH="), "script:\n{script}");
269 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 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 let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
316 assert!(!script1.contains("aliases.sh"));
317
318 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 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 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}